Fork me on GitHub

上周面了百度,问的很细~

上周刚刚面了百度,问的问题不算很难,但却很细,我把这些面试题和答案都整理出来了,一起来看吧。

重点介绍一个你觉得有意义的项目?

回答技巧和思路:

  1. 介绍的项目业务难度和技术难点要高一些,最好是微服务项目。
  2. 简明扼要的讲清楚项目核心板块的业务场景即可,切忌不要讲的太细和太久,这只是面试官要考察你技术问题的一个触手。
  3. 讲清楚自己在项目中负责的模块。
  4. 讲清楚项目的亮点是啥。
  5. 讲清楚遇到了哪些棘手的问题?以及最终的解决方案。

synchronize锁升级过程?

synchronized 锁升级有两个版本:

  1. JDK 1.6 synchronized 锁升级:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
  2. JDK 15 synchronized 锁升级:无锁 -> 轻量级锁 -> 重量级锁。

注意:回答的过程中,最好能讲明白每种锁的概念和特征,以及为什么 JDK 15 要废除偏向锁?

synchronize作用在普通方法和静态方法上有什么区别?

synchronized 作用在静态方法是类级别的,而作用在普通方法是对象实例级别,因此作用在静态方法时,锁范围更大、性能也更低。

synchronized实现原理?

synchronized 同步锁是通过 JVM 内置的 Monitor 监视器实现的,而监视器又是依赖操作系统的互斥锁 Mutex 实现的。

例如,以下代码中使用了 synchronized 修饰:

public class SynchronizedToMonitorExample {
    public static void main(String[] args) {
        int count = 0;
        synchronized (SynchronizedToMonitorExample.class) {
            for (int i = 0; i < 10; i++) {
                count++;
            }
        }
        System.out.println(count);
    }
}

当我们将上述代码编译成字节码之后,得到的结果是这样的:

从上述结果我们可以看出,在 main 方法中多了一对 monitorenter 和 monitorexit 的指令,它们的含义是:

  • monitorenter:表示进入监视器。
  • monitorexit:表示退出监视器。

由此可知 synchronized 是依赖 Monitor 监视器实现的。

volatile作用和实现原理?

volatile 作用有以下两个:

  1. 保证内存可见性;
  2. 保证有序性(禁止指令重排序)。

① 内存可见性实现原理

volatile 内存可见性主要通过 lock 前缀指令实现的,它会锁定当前内存区域的缓存(缓存行),并且立即将当前缓存行数据写入主内存(耗时非常短),回写主内存的时候会通过 MESI 协议使其他线程缓存了该变量的地址失效,从而导致其他线程需要重新去主内存中重新读取数据到其工作线程中。

什么 MESI 协议?

MESI 协议,全称为 Modified, Exclusive, Shared, Invalid,是一种高速缓存一致性协议。它是为了解决多处理器(CPU)在并发环境下,多个 CPU 缓存不一致问题而提出的。
MESI 协议定义了高速缓存中数据的四种状态:

  1. Modified(M):表示缓存行已经被修改,但还没有被写回主存储器。在这种状态下,只有一个 CPU 能独占这个修改状态。
  2. Exclusive(E):表示缓存行与主存储器相同,并且是主存储器的唯一拷贝。这种状态下,只有一个 CPU 能独占这个状态。
  3. Shared(S):表示此高速缓存行可能存储在计算机的其他高速缓存中,并且与主存储器匹配。在这种状态下,各个 CPU 可以并发的对这个数据进行读取,但都不能进行写操作。
  4. Invalid(I):表示此缓存行无效或已过期,不能使用。

MESI 协议的主要用途是确保在多个 CPU 共享内存时,各个 CPU 的缓存数据能够保持一致性。当某个 CPU 对共享数据进行修改时,它会将这个数据的状态从 S(共享)或 E(独占)状态转变为 M(修改)状态,并等待适当的时机将这个修改写回主存储器。同时,它会向其他 CPU 广播一个“无效消息”,使得其他 CPU 将自己缓存中对应的数据状态转变为I(无效)状态,从而在下次访问这个数据时能够从主存储器或其他 CPU 的缓存中重新获取正确的数据。

这种协议可以确保在多处理器环境中,各个 CPU 的缓存数据能够正确、一致地反映主存储器中的数据状态,从而避免由于缓存不一致导致的数据错误或程序异常。

② 有序性实现原理

volatile 的有序性是通过插入内存屏障(Memory Barrier),在内存屏障前后禁止重排序优化,以此实现有序性的。

什么是内存屏障?

内存屏障(Memory Barrier 或 Memory Fence)是一种硬件级别的同步操作,它强制处理器按照特定顺序执行内存访问操作,确保内存操作的顺序性,阻止编译器和 CPU 对内存操作进行不必要的重排序。内存屏障可以确保跨越屏障的读写操作不会交叉进行,以此维持程序的内存一致性模型。

在 Java 内存模型(JMM)中,volatile 关键字用于修饰变量时,能够保证该变量的可见性和有序性。关于有序性,volatile 通过内存屏障的插入来实现:

  • 写内存屏障(Store Barrier / Write Barrier): 当线程写入 volatile 变量时,JMM 会在写操作前插入 StoreStore 屏障,确保在这次写操作之前的所有普通写操作都已完成。接着在写操作后插入 StoreLoad 屏障,强制所有后来的读写操作都在此次写操作完成之后执行,这就确保了其他线程能立即看到 volatile 变量的最新值。
  • 读内存屏障(Load Barrier / Read Barrier): 当线程读取 volatile 变量时,JMM 会在读操作前插入 LoadLoad 屏障,确保在此次读操作之前的所有读操作都已完成。而在读操作后插入 LoadStore 屏障,防止在此次读操作之后的写操作被重排序到读操作之前,这样就确保了对 volatile 变量的读取总是能看到之前对同一变量或其他相关变量的写入结果。

通过这种方式,volatile 关键字有效地实现了内存操作的顺序性,从而保证了多线程环境下对 volatile 变量的操作遵循 happens-before 原则,确保了并发编程的正确性。

JVM内存布局?

《Java虚拟机规范》中将 JVM 运行时数据区域划分为以下 5 部分:

  1. 程序计数器(Program Counter Register):用于存储当前线程执行的字节码指令的地址,在多线程环境中,程序计数器用于实现线程切换,保证线程恢复执行时能够继续从正确的位置执行代码。
  2. Java 虚拟机栈(Java Virtual Machine Stacks):用于存储方法调用和局部变量(方法内部定义的变量),在方法调用和返回时,虚拟机栈用于保存方法的调用帧,包括方法的局部变量、操作数栈、方法返回地址等。
  3. 本地方法栈(Native Method Stack):与虚拟机栈类似,本地方法栈用于执行本地(Native)方法。
  4. Java 堆(Java Heap):JVM 中最大的一块内存区域,用于存储对象实例。所有的对象都在堆中分配内存。
  5. 方法区(Methed Area):用于存储类的元数据信息,包括类的结构、字段、方法、静态变量、常量池等。

如下图所示:
image.png

栈和方法区会有OOM吗?

栈和方法区都会出现 OOM,它们的 OOM 发生场景如下:

  • 栈内存主要用于存储方法的栈帧,包括局部变量、操作数栈、方法返回地址等。当一个线程请求的栈深度超过 JVM 允许的最大深度时(默认情况下这个值是比较大的,但可以通过-Xss参数调整),会抛出 StackOverflowError 异常。此外,如果 JVM 尝试动态扩展栈空间大小但无法获得足够的内存,也可能抛出 OutOfMemoryError 异常。不过,这种情况相对较少见,因为栈空间一般在启动时就已经固定或者有比较确定的上限。
  • 当方法区(或元空间)中存放的类、常量、静态变量等信息超过了 JVM 分配给它的内存大小时(可通过 -XX:MaxMetaspaceSize 等参数进行设置),就会抛出 OutOfMemoryError。特别是当应用动态加载大量的类或类加载器无法被垃圾回收时,容易出现方法区的内存溢出。

父线程创建多个子线程可能会导致哪块内存溢出?

可能会导致内存溢出的区域有以下几个:

  1. Java 栈内存溢出:每个线程都有自己的栈,用于存储方法调用时的方法信息、局部变量等数据。如果线程的栈设置过大,或者线程递归深度过深,可能会导致栈内存溢出(StackOverflowError)。若栈大小动态扩展受限于系统可用内存,则可能抛出 OutOfMemoryError。
  2. 堆内存溢出:当创建大量线程时,每个线程可能会创建和管理多个对象,这些对象都存储在堆中,当对象超过 JVM 配置的最大堆内存时(通过 -Xmx 参数设置),可能会导致 java.lang.OutOfMemoryError: Java heap space 错误。
  3. 方法区溢出: 在多线程应用中,当线程中的代码涉及到动态类加载(例如使用线程上下文类加载器加载不同的类)时,可能会导致方法区(或其替代品 Metaspace)内存的快速增长。方法区存储了类的元数据,如果这部分内存达到 JVM 配置的最大值(通过 -XX:MaxMetaspaceSize 设置),也会引发 java.lang.OutOfMemoryError,错误信息可能涉及“Metaspace”。

CMS和G1收集器的区别?

CMS 和 G1 区别主要有以下几个:

  1. 目标和定位不同:CMS 目标和定位是最短停顿时间;G1 除了追求低停顿外,还允许用户设定期望的最大停顿时间(Pause Time Target),更加灵活地平衡吞吐量和响应时间,适合大规模数据的应用。
  2. 作用区域不同:CMS 是老年代垃圾回收器;G1 是全代(新生代+老年代)垃圾回收器。
  3. 使用算法不同:CMS 使用的是“标记-清除”算法,所以会产生内存碎片;而 G1 使用的是“复制”算法所以不会有内存碎片。
  4. 适用场景不同:小内存和 JDK 8 之前通常会使用 CMS 垃圾回收器;而大内存管理和 JDK 9+ 以上环境通常会使用 G1 垃圾回收器。

说说创建一个对象的过程?

创建一个对象的过程如下:

  1. 类加载检查:当通过 new 关键字创建一个对象时,JVM 首先会检查该对象的类是否已经被加载并初始化了。如果尚未加载,JVM 将先进行类的加载过程,然后调用该类的构造器以完成初始化。
  2. 分配内存空间:类加载完成后,JVM 会在 Java 堆中为新创建的对象分配足够的内存空间。对象所需的内存大小在类加载过程中就已经确定。内存的分配方式取决于 Java 堆内存是否规整,可以选择“指针碰撞”或“空闲列表”两种不同的分配方式。
  3. 初始化零值:内存分配之后,JVM 会对对象的普通成员变量进行初始化为零值,比如 int 类型初始化为 0,Integer 类型初始化为 null。这一步骤确保了对象的成员字段在不经过显式初始化的情况下也能被直接使用。
  4. 设置对象头:然后 JVM 需要对对象的对象头进行设置,这包括对象的元数据信息、GC 分代年龄、 hashCode 以及锁标记等。对象头的设置对于对象的后续使用至关重要。
  5. 执行构造方法:最后,JVM 将执行对象的构造方法。这一步是开发者可以控制的,可以在构造方法中添加自定义的初始化代码,以实现特定的业务逻辑。构造方法执行完成后,一个完全初始化且可直接使用的对象才算创建成功。

注意:对象创建过程和类加载机制(加载、验证、准备、解析、初始化)略有不同。

本文已收录到我的面试小站 www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

posted @ 2024-06-17 15:41  磊哥|www.javacn.site  阅读(86)  评论(0编辑  收藏  举报