JVM 面试题

1、什么情况下会发生栈内存溢出。

栈内存溢出通常发生在以下几种情况中:

  1. 函数递归调用过深: 当函数递归调用自身且没有合适的退出条件时,每次递归调用都会在栈上分配一个新的栈帧来存储局部变量、返回地址等信息。如果递归层次过多,未被释放的栈帧会不断累积,直到栈空间耗尽,从而导致栈溢出。

  2. 大量局部变量占用栈空间: 如果一个方法内部声明了大量局部变量(尤其是大对象或数组),每个栈帧都需要为这些局部变量分配空间。若这些变量占用的空间总和超过了栈所能提供的大小,则会发生栈溢出。

  3. 线程栈深度太大: Java虚拟机或其他语言实现中的每个线程都有其私有的栈空间,用于方法调用。当线程请求的栈深度超过JVM允许的最大深度(可以通过JVM参数调整)时,也会抛出StackOverflowError异常。

  4. 无限循环或死锁: 在特定编程错误的情况下,例如两个或多个方法互相调用形成了无法终止的循环,也会持续压入栈帧,最终导致栈溢出。

  5. 栈空间固定且无法扩展: 对于一些操作系统或环境,栈空间是固定的并且不能动态扩展,一旦固定大小的栈空间被占满,即使后续操作需要很小的空间也无法满足,从而发生溢出。

  6. xss 设置过小

综上所述,栈内存溢出主要是由于程序运行过程中,函数调用栈的使用量超出了系统预留给栈的固定或者可扩展的最大容量。解决办法通常包括优化递归算法以减少递归深度,减小局部变量的使用,合理设置虚拟机栈的大小限制,以及避免死循环或无终止条件的递归调用。

2、说一下 JVM 的主要组成部分及其作用?

JVM(Java Virtual Machine,Java虚拟机)是运行Java应用程序的核心组件,它为Java字节码提供了运行环境,并负责将字节码转换为特定平台的机器指令执行。以下是JVM的主要组成部分及其作用:

  1. 类加载器子系统 (Class Loader Subsystem)

    • 类加载器负责查找和加载程序运行时需要的类文件(.class),按照一定的委托加载机制,将类文件中的二进制数据读入内存并转化为方法区内的运行时类结构。
    • 它确保了类的安全性和隔离性,例如通过不同的类加载器实现类的版本控制、模块化和命名空间管理。
  2. 运行时数据区 (Runtime Data Areas)

    • 运行时数据区是JVM在内存中划分的不同区域,用于存储程序运行时的各种数据:
      • 程序计数器:每个线程都有一个,用来记录当前正在执行的字节码指令地址。
      • 虚拟机栈:每个线程拥有自己的栈,用于存放方法调用时的局部变量表、操作数栈等信息,方法调用和返回在这里完成。
      • 本地方法栈:与虚拟机栈类似,但服务于native方法。
      • 堆内存:所有线程共享,主要用于存储对象实例,包括对象实例变量和数组。
      • 方法区(或元空间):存储已被加载的类信息、常量池、静态变量、即时编译后的代码等。
  3. 执行引擎 (Execution Engine)

    • 执行引擎负责解释和执行字节码,现代JVM普遍采用Just-In-Time (JIT) 编译器对热点代码进行动态编译为机器码以提高性能。
    • 它会根据字节码指令进行操作,包括类型检查、操作数栈管理、方法调用以及异常处理等。
  4. 本地方法接口 (Native Interface)

    • 本地方法接口允许Java代码通过JNI(Java Native Interface)调用其他语言(如C、C++)编写的本地库函数。
    • 它使得Java可以和其他语言编写的系统库进行交互,扩展Java的功能,比如访问底层操作系统资源或者调用硬件设备API。

这些组成部分共同协作,实现了Java语言"一次编写,到处运行"的特性,保证了Java程序可以在不同平台上具有良好的可移植性和兼容性。

3、详解 JVM 内存模型

JVM(Java Virtual Machine,Java虚拟机)内存模型是Java程序运行的基础架构,它将计算机物理内存划分为不同的逻辑区域,以便更好地管理和优化数据存储和访问。以下是JVM内存模型的主要组成部分以及它们各自的作用:

  1. 程序计数器 (Program Counter Register)

    • 程序计数器是每个线程私有的,是一个非常小的内存空间,用于指示当前线程执行字节码的位置。
    • 在执行过程中,它保存着下一条要被执行的指令地址,对于分支、循环、方法调用等控制流操作提供支持。
  2. 虚拟机栈 (Java Virtual Machine Stacks)

    • 每个线程都有一个独立的虚拟机栈,用于存放栈帧(Stack Frame)。栈帧对应于每次方法调用时的内存空间,包含局部变量表、操作数栈、动态链接、方法返回地址等信息。
    • 局部变量表用来存储方法的参数和局部变量;操作数栈在方法执行期间作为计算的临时工作区;动态链接确保正确解析到被调用的方法或字段的直接引用;方法返回地址记录了方法正常退出或者异常退出时需要恢复的下一条指令位置。
  3. 本地方法栈 (Native Method Stacks)

    • 与虚拟机栈类似,本地方法栈也是线程私有的,主要服务于native方法的调用,当某个线程调用的是由其他语言(如C、C++)实现的本地方法时,相应的调用信息会储存在本地方法栈中。
  4. 堆 (Heap)

    • 堆是所有线程共享的一块内存区域,主要用于存储对象实例以及数组。在Java中,几乎所有的对象都是在这个区域分配内存。
    • 堆内存又可以细分为多个区域,如新生代(Eden区、From Survivor区、To Survivor区)、老年代(Tenured Generation)和元空间/永久代(Metaspace,在Java 8及之后版本已经移除永久代并改为元空间,位于本地内存)。
    • 垃圾回收机制主要针对的就是堆内存,对不再使用的对象进行回收以释放内存资源。
  5. 方法区 (Method Area)

    • 方法区也是各个线程共享的内存区域,存储已被加载的类信息、常量池、静态变量、即时编译后的代码等数据。
    • 在Java 7及其以前的版本中,这部分区域被称为"永久代",而在Java 8及其以后的版本中,则使用元空间替代了永久代。
  6. 运行时常量池 (Runtime Constant Pool)

    • 运行时常量池是方法区的一部分,它包含编译期生成的各种字面量和符号引用,在类加载后存放在方法区内。
    • 这些常量池内容在运行期间也可能发生变化,例如String类的intern()方法可能会导致常量池中添加新的字符串常量。

通过合理地管理这些内存区域,JVM能够有效地为Java应用程序提供内存服务,同时通过垃圾收集机制自动处理内存分配和回收问题,降低了程序员的负担,并确保了系统稳定性和性能。

4 JVM 内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为 Eden 和 Survivor。

JVM内存分为新生代、老年代和持久代(或元空间,取决于Java版本)主要是为了优化垃圾收集策略并提高内存管理效率。这些不同的区域有不同的特点和用途:

新生代(Young Generation)

新生代是所有新创建的对象最初分配的地方。它主要存放生命周期短的临时对象。新生代被进一步划分为三个部分:Eden区和两个Survivor区(通常是Survivor0和Survivor1,也称为from和to)。

  • Eden区:大部分新生成的对象首先会被放入Eden区。当发生Minor GC时,Eden区中的存活对象会被复制到其中一个Survivor区中。

  • Survivor区:Survivor区用来存储在GC后仍然存活下来的年轻对象。在每次Minor GC后,Eden区和其中一个Survivor区的存活对象会被复制到另一个Survivor区中。这种设计是为了减少直接晋升到老年代的对象数量,并通过复制算法来消除内存碎片。

划分成多个区域的原因:

  • 复制清理法(Copying Collector)可以提供更好的内存利用率和更高效的回收速度,因为它只需要处理一部分活跃对象。
  • Survivor区的存在允许一些生命周期稍长但不至于立即进入老年代的年轻对象有机会在Survivor区之间多次移动,增加其在新生代存活的时间。

老年代(Old Generation / Tenured Generation)

老年代用于存放经过多次Minor GC仍然存活的对象,或者是在新生代中占用大量连续内存的大对象(可能直接分配在老年代)。老年代的对象通常生命周期较长,因此它的垃圾回收不那么频繁,但执行Major GC(Full GC)时所需时间更长且对系统影响更大。

持久代(Perm Generation)与元空间(Metaspace)

在Java 7及更早版本中,JVM有一个称为持久代的区域,主要用于存储类信息、常量池、方法描述符等静态数据。从Java 8开始,永久代被移除,这部分内容被转移到了一个名为元空间的新区域,元空间直接使用的是操作系统的本地内存,而不是堆的一部分。

划分这些区域的主要目的是为了更好地进行内存管理和垃圾回收,根据对象的生命周期差异采用不同的回收策略,从而保证整体性能最优。

5 jvm 分代垃圾回收算法时,分别使用了什么算法

在Java虚拟机(JVM)中,分代垃圾回收算法是基于对象生命周期的观察结果设计的,它将堆内存划分为不同的区域,每个区域采用最适合其特点的垃圾收集算法。主要分为以下几代和相应的常用算法:

  1. 新生代(Young Generation)

    • 新生代通常使用"复制"算法变体。具体实现上,新生代被划分为Eden区、Survivor0区和Survivor1区。
      • 当发生Minor GC时,首先会清理Eden区和其中一个Survivor区中的对象。
      • 存活的对象会被复制到另一个Survivor区中。
      • 经过多次GC仍然存活的对象将会晋升至老年代。
  2. 老年代(Old/Tenured Generation)

    • 老年代由于存放的是生命周期较长的对象,不适合频繁地进行复制操作。
    • 因此,在老年代常采用"标记-清除"或"标记-整理"(Mark-Sweep-Compact)等算法或者它们的混合变种,如G1垃圾回收器使用的部分并发压缩(STW+并发标记+并发清理+最终并发压缩)策略。
      • "标记-清除"会在标记阶段找出所有活动对象,然后在清除阶段删除未被标记的对象,但会产生内存碎片。
      • "标记-整理"则在清除不活动对象后,还会对剩余的活动对象进行整理,以消除碎片。
  3. 元空间(Metaspace)或永久代(PermGen,在Java 8之前)

    • 元空间存储类的元数据,不采用分代回收机制,而是依赖于操作系统来管理内存。
    • 在某些情况下,如果元空间不足也会触发GC行为,但与堆上的对象回收机制不同。

现代JVM垃圾回收器(如Parallel/Serial GC、CMS、G1以及ZGC、Shenandoah等低延迟收集器)往往结合了多种算法的优点,并根据实际情况动态调整策略,以达到高效、稳定的内存管理和垃圾回收效果。

6 常见的 jvm 配置

在Java虚拟机(JVM)中,常见的配置参数主要用于调整堆内存大小、垃圾收集器、栈大小以及其他与性能和资源限制相关的选项。以下是一些常用的JVM配置参数:

堆内存相关:

  • 初始堆大小-Xms<size> 设置JVM初始分配给堆的内存大小。

  • 最大堆大小-Xmx<size> 设置JVM允许的最大堆内存大小。

  • 年轻代大小-Xmn<size> 设置年轻代(Young Generation)的大小。

  • 持久代/元空间大小(取决于Java版本)

    • 对于Java 8及以前版本:-XX:PermSize=<size>-XX:MaxPermSize=<size>(永久代大小)
    • 对于Java 8以后版本:-XX:MetaspaceSize=<size>-XX:MaxMetaspaceSize=<size>(元空间大小)

垃圾收集器选择与调优:

  • 选择垃圾收集器-XX:+UseSerialGC(串行收集器) -XX:+UseParallelGC(并行收集器) -XX:+UseConcMarkSweepGC(CMS并发标记清除收集器,适用于低延迟场景) -XX:+UseG1GC(G1垃圾收集器) -XX:+UseZGC-XX:+UseShenandoahGC(ZGC或Shenandoah,低延迟垃圾收集器,Java 11+)

  • 设置暂停时间目标 (例如对于G1或ZGC): -XX:MaxGCPauseMillis=<value>

  • 调整年轻代与老年代比例-XX:NewRatio=<n>(新生代与老年代的空间比,默认为2,即新生代占1/3,老年代占2/3)

线程栈大小:

  • 线程栈大小-Xss<size> 设置每个线程栈的大小。

其他重要参数:

  • 打印GC日志-XX:+PrintGCDetails(详细GC日志) -XX:+PrintGCDateStamps(添加时间戳到GC日志) -XX:+PrintTenuringDistribution(显示对象晋升年龄信息)

  • 防止过早优化(仅用于调试)-XX:+DisableExplicitGC(禁用System.gc()调用)

  • 代码缓存大小-XX:ReservedCodeCacheSize=<size>(设置方法区或代码缓存大小)

这些只是众多JVM配置参数中的一部分,实际应用中还需要根据业务需求、服务器硬件资源以及应用程序特点来合理地选择和调整这些参数。同时,不同的JDK版本可能对某些参数的支持和默认行为有所差异,因此在进行配置时需参考对应JDK版本的官方文档。

7 zgc

ZGC(Z Garbage Collector)是Oracle公司开发的一种低延迟的垃圾收集器,首次在Java 11中作为实验特性引入,并在后续版本中逐步完善。ZGC的设计目标是在处理大量内存(TB级别)的同时保持极低的暂停时间(小于10毫秒),适用于对响应时间和数据集大小有严格要求的应用场景。

ZGC的主要特点包括:

  1. 并发标记与并发压缩

    • ZGC采用颜色指针技术,将对象移动和更新的过程设计为并发执行,大大降低了STW(Stop-The-World)阶段的时间。
  2. 可伸缩性

    • 支持非常大的堆内存,最大可达4TB(在某些Java版本中支持更大)。
  3. 低延迟

    • 设计目标是使得大部分GC暂停时间不超过10ms,即使面对大内存也能提供良好的性能表现。
  4. 内存碎片控制

    • 使用了线性空间回收算法,能够有效地避免内存碎片问题,提高内存利用率。
  5. 并发引用处理

    • 处理跨代引用时也是并发进行的,进一步减少STW时间。
  6. NUMA-aware

    • 支持Non-Uniform Memory Access (NUMA) 架构,可以更好地利用多核CPU的内存访问特性。
  7. 高效内存分配

    • 使用Region内存布局,通过预分配空闲区域来实现快速的对象分配。

使用ZGC时需注意,尽管其具有优秀的低延迟特性,但并不一定适合所有场景。例如,在内存较小或CPU核心数有限的情况下,传统的垃圾收集器如G1或者CMS可能在吞吐量方面表现更优。因此,在选择垃圾收集器时应根据具体应用的需求、硬件资源以及负载情况来综合评估。

8 深拷贝和浅拷贝

深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是计算机科学中,特别是在面向对象编程中关于复制对象时的两种不同策略。

浅拷贝

  • 当一个对象被浅拷贝时,新创建的对象仅复制了原始对象的引用,而不复制其内部所引用的对象。
  • 因此,如果原始对象的成员变量是基本数据类型,那么它们的值会被复制;但如果成员变量是指向其他对象的引用,则新对象将共享这些引用,指向与原对象相同的内存地址。
  • 修改浅拷贝得到的新对象中的引用类型成员变量时,会影响到原对象对应的这部分内容。

例如,在Java中:

复制代码

Java

1class A {
2    int value;
3    B b; // B是另一个类的对象
4
5    public A(int value, B b) {
6        this.value = value;
7        this.b = b;
8    }
9}
10
11A original = new A(10, new B("original"));
12A shallowCopy = new A(original.value, original.b);
13shallowCopy.value = 20; // 不会影响original
14shallowCopy.b.setValue("shallow copied"); // 这里修改的是shared的B对象,因此original.b也会受影响

深拷贝

  • 深拷贝则会创建一个新的对象,并复制所有属性到新对象中,对于引用类型的成员变量,不仅复制其引用,还会递归地复制其所引用的对象,直到所有的成员都是基本数据类型为止。
  • 这意味着通过深拷贝获得的新对象在内存中存储了原始对象所有属性的一份独立副本,修改新对象不会影响到原始对象。

例如,在Java中实现深拷贝通常需要自定义clone方法或序列化实现:

复制代码

Java

1class A implements Cloneable {
2    int value;
3    B b;
4
5    @Override
6    protected Object clone() throws CloneNotSupportedException {
7        A copy = (A) super.clone();
8        // 对于引用类型b进行深拷贝
9        copy.b = (B) b.clone(); // 假设B类也实现了Cloneable接口并重写了clone方法
10        return copy;
11    }
12
13    // ... 其他代码
14}
15
16A original = new A(10, new B("original"));
17try {
18    A deepCopy = (A) original.clone();
19    deepCopy.value = 30; // 不会影响original
20    deepCopy.b.setValue("deep copied"); // 这里的b是深拷贝后的独立对象,所以original不受影响
21} catch (CloneNotSupportedException e) {
22    e.printStackTrace();
23}

总之,浅拷贝只是简单地复制了对象的引用,而深拷贝则是创建了一个完全独立的对象副本。

9 为什么要分为 Eden 和 Survivor?为什么要设置两个 Survivor 区?

将Java虚拟机(JVM)的新生代内存区域分为Eden区和两个Survivor区(通常称为from和to或S0和S1)的主要原因如下:

  1. 减少晋升到老年代的对象数量

    • 大部分新创建的对象首先会被分配到Eden区。在进行Minor GC时,只有那些经历了至少一次垃圾回收仍然存活的对象才会被复制到Survivor区。
    • 这种设计使得短生命周期的对象可以在新生代内部就被清理掉,避免了大量对象过早晋升至老年代,从而减少了老年代的压力和Full GC的发生频率。
  2. 内存利用率与碎片化

    • 如果没有Survivor区,每次Minor GC后存活的对象都会直接进入老年代,容易导致老年代空间使用不均和内存碎片。
    • 通过设置Survivor区,并采用复制算法(Copying Collector),可以保持新生代内的连续内存空间,提高内存利用率并减少碎片。
  3. 提高GC效率

    • Eden区和Survivor区之间通过复制操作来进行垃圾回收,这种方式相对于标记-清除或标记-整理等其他算法,在处理大部分是短暂对象的新生代时更为高效。
  4. 两个Survivor区的设计

    • 使用两个Survivor区主要是为了方便进行对象在新生代内部的移动。每次GC后,Eden区和其中一个Survivor区中存活的对象会复制到另一个Survivor区中。
    • 一个空闲的Survivor区确保了有足够的空间来容纳从Eden区复制过来的存活对象,同时避免了内存碎片问题,因为只会在空闲Survivor区内分配内存。
    • Survivor区间的对象还会根据其年龄进行动态调整,达到一定年龄阈值的对象会被晋升到老年代。

通过这种分区分阶段的管理方式,JVM能够更有效地管理新生代内存,降低系统的暂停时间,提升整体性能表现。

10 说一下堆栈的区别?

堆栈(Heap and Stack)是计算机内存中的两个不同区域,它们在内存管理、数据存储和生命周期上有显著区别:

堆(Heap):

  1. 位置:位于程序的动态内存区域。
  2. 分配方式:程序员通常使用new关键字(如C++或Java)或malloc函数(如C语言)动态地为对象分配空间。分配的空间大小不固定,可以根据需要调整。
  3. 生存期:堆中分配的对象由垃圾回收机制(如Java的GC)或者手动释放(如C++的delete/malloc)来决定其生命周期。直到没有指向该对象的引用且垃圾回收器运行后,才会被系统回收。
  4. 内容:主要存放复杂的数据结构,如对象实例以及大块的动态分配数据。
  5. 访问方式:通过指针或引用进行间接访问。

栈(Stack):

  1. 位置:位于程序的静态内存区域,一般存放在内存的高地址部分。
  2. 分配方式:由编译器自动分配和释放,空间大小在编译期间确定,并且栈帧的创建与销毁伴随着函数调用与返回。
  3. 生存期:栈上变量的生命周期与所在的作用域直接相关,一旦作用域结束(例如函数执行完毕),这些变量就会自动被清理,无需程序员手动管理。
  4. 内容:主要存放基本类型变量、局部变量、函数参数和返回地址等,其中局部对象(对于C++来说,非Java)也存在栈上。
  5. 访问方式:直接通过相对固定的偏移量(相对于栈顶或栈底)访问,速度较快,且内存访问安全可靠,不会发生指针越界等问题。

总结:

  • 堆主要用于存储不确定大小、生命周期长且复杂的动态数据。
  • 栈则负责存储确定大小、生命周期短且简单的基本类型及局部变量,适合快速高效的存取操作。
  • 堆上的内存分配更加灵活但也容易导致内存泄漏问题,而栈上的内存管理较为严格,但资源有限,不能存放大量数据。

11 你知道哪几种垃圾收集器,各自的优缺点,重点讲下 cms 和 G1,包括原理,流程,优缺点。

垃圾收集器是Java虚拟机(JVM)中用于自动回收不再使用的对象所占用内存空间的组件。以下是一些常见的垃圾收集器:

  1. Serial/Serial Old

    • 优点:简单,适用于单核机器或小内存环境。
    • 缺点:仅使用一个线程进行垃圾收集,会导致"Stop-The-World"停顿时间较长。
  2. Parallel/New/Parallel Old (PS/ParNew/PSScavenge/ParallelScavenge)

    • 优点:多线程并行执行,提升吞吐量。
    • 缺点:在CPU核心数较大时,GC期间可能造成较大的系统压力;仍然存在STW问题。
  3. CMS (Concurrent Mark Sweep)

    • 原理和流程:

      • 初始标记阶段(STW):标记从根集合直接可达的对象。
      • 并发标记阶段:在整个堆中找出所有可到达的对象。
      • 重新标记阶段(STW):修正并发标记过程中因用户程序继续运行而产生变动的对象引用关系。
      • 并发清除阶段:删除未被标记为存活的对象。
    • 优点:

      • 强调低延迟,大部分工作并发进行,减少STW的时间。
      • 面向老年代设计,目标是尽可能降低Major GC导致的停顿。
    • 缺点:

      • 对处理器资源需求较高,可能导致应用程序性能下降。
      • 在并发标记和并发清除阶段会与应用线程共享CPU资源,可能影响响应速度。
      • 可能出现并发模式失败(Concurrent Mode Failure),需要fallback到Serial Old进行全堆扫描。
      • CMS不擅长处理浮动垃圾,如果并发周期内产生的新垃圾过多,可能会触发额外的Full GC。
  4. G1 (Garbage First)

    • 原理和流程:

      • 整个堆被划分为多个大小相等的Region,G1采用有预测性的垃圾回收策略,优先回收垃圾最多且开销最小的区域。
      • G1包含了年轻代和老年代的概念,但没有明确的固定分区,而是根据对象的生命周期动态管理。
      • 其收集过程包括初始标记、并发标记、最终标记、清理以及混合回收(Mixed GC)阶段。
    • 优点:

      • 目标是实现可预测的暂停时间,允许用户设置最大暂停时间阈值。
      • 同时具备较高的吞吐量和较低的停顿时间。
      • 收集时按Region进行,可以并发地对部分区域进行垃圾回收。
      • 能够避免全局的Full GC,通过并发的全局标记来完成整个堆的整理。
    • 缺点:

      • 相比CMS,G1增加了更多的后台任务,可能消耗更多CPU资源。
      • 在特定条件下,尤其是大量小型对象分配场景下,可能会导致更频繁的GC活动。
      • 配置参数较多,调优复杂度相对较高。

总结来说,CMS和G1都是针对大型服务器应用优化的垃圾收集器,主要区别在于CMS更加侧重于减少Major GC停顿时间,而G1则致力于提供可预测的停顿时间和兼顾高吞吐量。G1的设计理念较新,逐步取代了CMS成为默认推荐的老年代垃圾收集器。

11 队列和栈是什么?有什么区别?

队列(Queue)和栈(Stack)是两种基本的线性数据结构,它们在存储、管理和操作数据的方式上具有明显的区别。

队列(Queue)

  • 队列遵循"先进先出"(First In First Out, FIFO)原则。新加入队列的元素会被放置在队列的尾部(称为enqueue或enqueue operation),而从队列中取出元素的操作发生在队列的头部(称为dequeue或 dequeue operation)。就像现实生活中排队等候服务的情景,最先到达的人(元素)会最先得到服务并离开队伍。
  • 在队列中,不允许直接访问或修改中间位置的元素,只能对两端进行操作。

栈(Stack)

  • 栈遵循"后进先出"(Last In First Out, LIFO)原则。新元素被压入栈顶(称为push操作),而删除或访问元素的操作也只在栈顶进行,即最后放入栈中的元素将首先被弹出栈(称为pop操作)。形象地说,栈就像是一个盘子堆叠或者弹簧夹的情况,最上面的盘子可以最快地取出来。
  • 同样,栈也不允许直接访问或修改非顶部元素,它提供的是对栈顶元素的操作。

总结起来,队列和栈的主要区别在于:

  1. 元素添加和删除的位置不同:队列是在一端添加(尾部),另一端删除(头部);栈则都在同一端(顶部)进行添加和删除操作。
  2. 访问顺序不同:队列遵循FIFO规则,栈遵循LIFO规则。
  3. 应用场景各异:队列常用于处理多任务队列、消息传递、缓冲区等需要有序且公平处理任务的场合;栈则广泛应用于函数调用栈、表达式求值、回溯算法等领域,其中需要保持最近添加的数据能够优先获取和移除的状态。

12 HotSpot 虚拟机对象探秘

HotSpot虚拟机是Oracle JDK和OpenJDK中最广泛使用的Java虚拟机实现,它在内存管理、垃圾收集等方面进行了深度优化。关于HotSpot虚拟机对象的探秘主要涉及以下几个方面:

  1. 对象布局

    • 在HotSpot虚拟机中,每个Java对象都由三个部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。
      • 对象头包含了必要的元数据,如类型指针(用于确定对象所属的类)、哈希码(如果已计算)、GC分代年龄、锁状态标志等信息。
      • 实例数据则是对象的具体成员变量值。
      • 对齐填充是为了确保对象大小满足特定机器架构上的字节对齐要求。
  2. 对象创建与初始化

    • new关键字创建一个Java对象时,首先会通过类加载机制找到对应的类元数据,然后在Java堆上分配足够的内存空间,并初始化对象头和实例数据。
    • 如果对象包含引用类型成员变量,这些引用也会被初始化为null或其他默认值。
  3. 对象访问定位

    • HotSpot虚拟机提供了两种主流的对象访问方式:句柄访问和直接指针访问。
      • 句柄访问:Java堆中对象的实例数据与方法区中的类元数据分别存储,对象中保存的是一个指向该对象类元数据的句柄地址。
      • 直接指针访问:对象的实例数据区域直接存储类元数据的地址,这样可以减少一次间接寻址的时间开销。
  4. 对象内存分配策略

    • 年轻代分配:大部分新创建的对象会被分配到年轻代的Eden区或Survivor区。
    • 大对象直接进入老年代:较大的对象(大于一定阈值)可能直接在老年代分配以避免频繁复制。
    • 跨代晋升:经过几次年轻代GC后仍存活的对象将晋升至老年代。
  5. 垃圾回收与对象标记

    • HotSpot虚拟机支持多种垃圾收集器,如Serial GC、Parallel GC、CMS、G1以及ZGC和Shenandoah等,它们采用不同的算法来识别并回收不再使用的对象。
    • 标记阶段通常包括根扫描、可达性分析、并发标记(某些GC器)等步骤来判断对象是否可达。
  6. 对象终结处理

    • 当垃圾收集器发现一个对象不再可到达时,会先调用其finalize()方法(如果存在且未执行过),然后才会真正回收对象占用的内存资源。
    • 注意,finalize()方法不应用于常规编程逻辑,因为它的执行时机不确定且可能造成性能问题。
  7. 内存优化

    • 对象分配和释放过程涉及到内存碎片的控制,例如TLAB(Thread Local Allocation Buffer)有助于线程本地快速分配小对象,减少锁竞争。
    • 内存压缩技术在某些垃圾收集器中使用,以解决内存碎片问题,比如G1和ZGC的动态重定位功能。

以上是对HotSpot虚拟机中对象生命周期及其内存管理的一个概述,具体细节会根据实际使用的垃圾收集器和相关配置有所不同。

13 JVM 内存模型的相关知识了解多少,比如重排序,内存屏障,happen-before,主内存,工作内存。

Java虚拟机(JVM)内存模型定义了Java程序在并发执行时如何处理和维护内存中变量的可见性和一致性。以下是对这些概念的简要介绍:

  1. 主内存与工作内存

    • 主内存:是所有线程共享的数据区域,主要存储对象实例、类静态变量以及数组等数据。
    • 工作内存:每个线程都拥有自己的工作内存,也称为本地内存或线程私有数据区。它包含了该线程对主内存中部分变量的副本。当线程需要读取或修改变量时,首先从主内存加载到工作内存,然后进行操作,完成后再将更新后的值刷新回主内存。
  2. 重排序(Reordering)

    • 在多核处理器或者编译器优化过程中,为了提高性能,可能会改变程序中语句的执行顺序,这种现象被称为指令重排序。但是,重排序必须遵循一定的规则,以确保单线程内的程序语义不会被改变,并且在多线程环境下不会影响到正确性。
  3. 内存屏障(Memory Barrier/Fence)

    • 内存屏障是一种同步机制,它可以确保某些内存操作的执行顺序,阻止处理器和编译器对指令进行重排序。内存屏障可以分为读屏障和写屏障,它们分别用于控制读操作和写操作的顺序关系,保证内存访问的一致性。例如,在多线程编程中,内存屏障可以确保一个线程对共享变量的修改对其他线程立即可见。
  4. happens-before原则

    • Java内存模型通过happens-before原则来规范不同线程间的操作之间的可见性关系。如果一个操作A happens-before 操作B,则意味着:

      • A的结果对B来说是可见的;
      • 如果A完成了对某个变量V的写入,那么任何后续从B读取该变量V的操作都将看到A写入的最新值;
      • A不会看到自己对V的写入结果被覆盖(除非存在数据竞争)。
    • happens-before原则包括一些隐式规定,如程序次序规则、监视器锁规则(解锁前的写入操作 happens-before 同一把锁的加锁操作)、volatile变量规则(对volatile变量的写入 happens-before 之后对该变量的读取)、传递性规则等。

通过理解这些概念,开发者可以更好地编写并发代码,避免因内存模型带来的不确定性导致的错误行为。同时,使用适当的同步工具如synchronized关键字、volatile关键字和Lock接口等,可以遵循内存模型的要求,确保并发环境下的正确性和一致性。

happens-before原则

happens-before原则是Java内存模型中定义的一种保证多线程环境下操作之间可见性和执行顺序的规则。它描述了在Java并发编程中,一个操作(动作)对另一个操作(动作)产生的影响,如果操作A happens-before 操作B,则可以得出以下结论:

  1. 可见性:操作A的结果对于操作B来说是可见的,即如果A修改了一个共享变量,那么B总是能看到这个修改后的值。

  2. 有序性:尽管在实际执行过程中重排序可能导致操作A和操作B的执行顺序变得不确定,但在内存模型层面来看,我们仍然可以通过happens-before原则确保它们之间的顺序关系。

  3. 无数据竞争假设:如果两个操作之间存在happens-before关系,并且它们都涉及同一个变量,那么这两个操作不会发生数据竞争问题。

Java内存模型中定义了多个happens-before规则:

  • 程序次序规则:在一个线程内部,按照程序代码的顺序,前面的操作happens-before后面的操作。

  • 监视器锁规则:对一个监视器锁(synchronized关键字)解锁的操作happens-before随后对同一把锁进行加锁的操作。

  • volatile变量规则:对volatile变量的写入操作happens-before随后对同一个volatile变量的读取操作。

  • 传递性:如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。

通过这些规则,程序员可以在没有显式同步的情况下推断出多线程环境中的行为预期,确保并发程序的正确性和一致性。

14 对象的创建

在Java中,对象的创建过程涉及以下几个步骤:

  1. 类加载

    • 当程序首次使用某个类时,JVM会通过类加载器(ClassLoader)找到相应的.class文件并进行加载。类加载包括加载、验证、准备、解析和初始化五个阶段。
  2. 内存分配

    • 在类加载完成后,当执行到new关键字时,JVM会在堆内存(Heap)为新对象分配内存空间。这部分内存将包含对象头(存储对象类型指针、锁状态等信息)、实例数据(成员变量)以及可能的填充字节以满足特定对齐要求。
  3. 构造函数调用

    • 内存分配完成后,JVM会调用对应的构造函数来初始化对象。构造函数负责设置对象内部状态,即给成员变量赋初始值。
    • 如果父类尚未初始化,则首先初始化父类,然后从最顶层的超类开始递归调用各个构造函数直至当前类。
  4. 对象引用

    • 创建完对象后,返回一个指向该对象的引用。这个引用通常被存储在栈内存(Stack)中的局部变量表或静态域中。
  5. 对象布局

    • 对象在内存中的布局分为三部分:对象头、实例数据和对齐填充。对象头包括对象标记、类型指针等;实例数据是对象的实际内容,即类中定义的所有字段的内容;对齐填充是为了保证对象大小是8字节的倍数,提高处理器效率。

总结起来,创建一个Java对象需要经过类加载、内存分配、初始化和引用建立等步骤。此外,垃圾回收机制还会确保在对象不再被引用时释放其占用的内存资源。

15 简单说说你了解的类加载器,可以打破双亲委派么,怎么打破。

Java中的类加载器(ClassLoader)是Java运行时环境的一部分,负责在运行时查找和加载类。根据类的全名(包括包名),类加载器将类的二进制数据转换成JVM可以直接处理的运行时数据结构。

Java标准类加载机制遵循"双亲委派模型",其核心原则是:当一个类加载器收到加载类的请求时,它首先不会自己尝试去加载这个类,而是把请求委托给父类加载器去完成,依次向上,直到顶层的启动类加载器(Bootstrap ClassLoader)。如果父类加载器无法加载该类,则由子类加载器尝试加载。

打破双亲委派模型: 尽管双亲委派模型是默认的行为,但在某些情况下,自定义类加载器可以并且确实会打破这种模型。通常,为了实现更灵活或特殊的需求,如隔离加载类、热替换代码等场景,需要定制类加载器并控制加载过程。

要打破双亲委派模型,可以在自定义类加载器中重写loadClass()方法,并在其中直接调用自己的findClass()方法或其他加载逻辑来加载特定类,而不是先委托给父加载器。以下是一个简单的示例:

复制代码

Java

1public class CustomClassLoader extends ClassLoader {
2    // 其他自定义加载逻辑...
3
4    @Override
5    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
6        // 首先检查是否已经加载过
7        Class<?> c = findLoadedClass(name);
8        if (c == null) {
9            try {
10                // 不经过父类加载器,直接尝试自己加载
11                c = findClass(name);
12            } catch (ClassNotFoundException e) {
13                // 如果找不到,再按照双亲委派的方式,交给父加载器加载
14                c = super.loadClass(name, resolve);
15            }
16        }
17        if (resolve) {
18            resolveClass(c);
19        }
20        return c;
21    }
22
23    @Override
24    protected Class<?> findClass(String name) throws ClassNotFoundException {
25        // 在这里实现自己的类查找逻辑,比如从非标准路径加载类字节码
26        byte[] b = loadClassData(name);
27        if (b == null) {
28            throw new ClassNotFoundException();
29        }
30        return defineClass(name, b, 0, b.length);
31    }
32
33    private byte[] loadClassData(String className) {
34        // 示例性方法,实际应该从某个源获取类字节码
35        // 这里假设是从某个目录读取.class文件
36        // 实际情况可能涉及网络加载、加密资源解密后加载等多种方式
37        return ...;
38    }
39}

通过这种方式,自定义类加载器可以在需要时优先于其父加载器执行类加载操作,从而打破了标准的双亲委派模型。然而,在实际应用中打破双亲委派模型应谨慎对待,因为这可能会导致类加载冲突、安全性和稳定性问题。

16 Java 的 util 包可以覆盖吗

Java中的java.util包是Java标准库的一部分,包含了许多通用的工具类和数据结构,如集合框架(List, Set, Map等)、日期时间处理类、随机数生成器、哈希码计算、枚举支持等。这个包是由JDK提供并内置在Java运行时环境中的。

覆盖java.util包本身 : 作为Java开发者,你不能直接覆盖或替换java.util包的内容,因为它是JRE的一部分,而且不在你的项目源代码控制范围内。

扩展或重写java.util包中的类 : 虽然你不能直接修改JDK提供的java.util包,但你可以:

  • 继承 :可以继承自该包中的类,并根据需要重写方法以实现自定义行为,例如创建一个自定义的List实现或Set实现。
  • 包装 :创建一个新的包装类,内部使用java.util包中的类,同时提供额外的功能或修改部分行为。
  • 使用第三方库:许多第三方库提供了对标准库功能的增强或替代实现,例如Apache Commons Collections或Google Guava等。

总之,尽管你无法直接修改或覆盖Java标准库中的java.util包,但是通过继承和组合的方式,完全可以在自己的代码中对其进行扩展和定制。

Java 的 lang 包可以覆盖吗

Java中的java.lang包同样也是Java标准库的核心部分,它包含了所有Java程序都会用到的基础类和接口。像ObjectStringClassSystem等关键类都位于此包中。

覆盖java.lang包本身 : 与java.util包一样,作为Java开发者,你不能直接覆盖或替换java.lang包的内容,因为它是由JDK提供并内置在Java运行时环境(JRE)的核心部分,不属于用户可修改的范围。

扩展或重写java.lang包中的类: 虽然不能直接修改这些类,但在某些特定情况下:

  • 继承 :对于可以被继承的类(例如Object),你可以编写子类并进行扩展,但是由于java.lang包中的许多类都是final的,因此不是所有的类都能被继承。
  • 使用代理(Proxy)机制:Java提供了动态代理机制,在一定程度上可以对方法调用进行拦截和自定义处理,但这并不是真正意义上的"覆盖"原有类。

然而,通常不建议也不支持对java.lang包中的核心类进行不必要的扩展或绕过其正常行为,因为这可能会导致不可预测的结果,并破坏Java语言规范和API约定。如果需要定制功能,应当寻找其他设计模式或者在合理范围内利用已有的API来进行扩展。

17 CMS与g1的区别

CMS(Concurrent Mark Sweep)和G1(Garbage-First)是Java HotSpot虚拟机中的两种垃圾回收器,它们在不同的时期被开发出来以解决不同场景下的内存管理和性能优化问题。

CMS(并发标记清除)收集器:

  • 特点:

    • 主要针对老年代的垃圾回收。
    • 强调低停顿时间,适用于对响应时间要求较高的应用。
    • 采用多线程并发标记和清除算法,大部分GC工作与应用线程并发执行,从而减少STW(Stop-The-World)的时间。
    • CMS由于并发清理,可能会产生内存碎片。
  • 过程:

    • 初始标记(Initial Mark):STW阶段,仅标记从根对象可达的对象。
    • 并发标记(Concurrent Mark):遍历整个堆,找出所有可达对象,与用户线程并发执行。
    • 重新标记(Remark):STW阶段,修正并发标记期间因用户程序继续运行而变动的引用关系。
    • 并发清除(Concurrent Sweep):删除不可达对象,与用户线程并发执行。

G1(Garbage-First)收集器:

  • 特点:

    • 全面面向服务端应用设计,适用于大内存、多处理器环境。
    • 同样追求低停顿时间,并且实现可预测的停顿时间模型。
    • 将整个堆划分为多个大小相等的Region,根据各个Region中垃圾的多少来优先回收价值最大的Region。
    • 使用了混合的垃圾回收算法,包括复制算法和标记整理算法,避免或减少了内存碎片。
    • G1能够进行部分收集,只处理一部分Region而不是全堆,这有助于控制暂停时间。
  • 过程:

    • 初始标记(Initial Marking):类似于CMS,标记出GC Roots直接可达的对象。
    • 并发标记(Concurrent Marking):遍历整个堆,识别存活对象和区域间的引用关系。
    • 最终标记(Final Marking):补充并发标记阶段遗漏的或者更新过的引用关系。
    • 筛选回收(Live Data Counting and Evacuation):根据统计信息选择回收收益最高的Region进行回收,其中包括复制存活对象到新的Region。

总结来说,CMS和G1都是为了降低垃圾回收带来的应用停顿,但G1的设计更先进,不仅实现了更低和更可预测的停顿时间,还解决了内存碎片的问题。同时,JDK9及更高版本中,G1已经取代CMS成为默认的老年代垃圾收集器。

相关推荐
吴冰_hogan3 小时前
Java虚拟机(JVM)的类加载器与双亲委派机制
java·开发语言·jvm
杰克逊的日记5 小时前
JVM调优实践篇
java·jvm·测试工具·jvm工具
工业甲酰苯胺10 小时前
JVM实战—JVM内存设置与对象分配流转
jvm
爽口泡菜12 小时前
垃圾回收算法
jvm
小娄写码13 小时前
线程池原理
java·开发语言·jvm
东阳马生架构21 小时前
JVM实战—3.JVM垃圾回收的算法和全流程
jvm
xiaolingting1 天前
Java 引用是4个字节还是8个字节?
java·jvm·引用·指针压缩
HUNAG-DA-PAO1 天前
Spring AOP是什么
java·jvm·spring
No regret.2 天前
JVM内存模型、垃圾回收机制及简单调优方式
java·开发语言·jvm