Java八股文——JVM「内存模型篇」

JVM的内存模型介绍一下

面试官您好,您问的"JVM内存模型",这是一个非常核心的问题。在Java技术体系中,这个术语通常可能指代两个不同的概念:一个是JVM的运行时数据区 ,另一个是Java内存模型(JMM) 。前者是JVM的内存布局规范 ,描述了内存被划分成哪些区域;后者是并发编程的抽象模型,定义了线程间如何通过内存进行通信。

我先来介绍一下JVM的运行时数据区,这通常是大家更常提到的"内存模型"。

一、 JVM运行时数据区 (The Structure)

根据Java虚拟机规范,JVM在执行Java程序时,会把它所管理的内存划分为若干个不同的数据区域。这些区域可以分为两大类:线程共享 的和线程私有的。

【线程共享区域】

这些区域的数据会随着JVM的启动而创建,随JVM的关闭而销毁,并且被所有线程共享。

  1. 堆 (Heap)

    • 这是JVM内存中最大的一块 。它的唯一目的就是存放对象实例数组 。我们通过new关键字创建的所有对象,都在这里分配内存。
    • 堆是垃圾回收器(GC) 工作的主要区域。为了方便GC,堆内存通常还会被细分为新生代(Eden区、Survivor区)和老年代。
  2. 方法区 (Method Area)

    • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码缓存等数据。
    • 可以把它理解为一个"元数据"区。
    • 在HotSpot JVM中,方法区的实现在不同JDK版本中有所演变:
      • JDK 7及以前 :方法区被称为 "永久代"(Permanent Generation) ,是堆的一部分。
      • JDK 8及以后 :永久代被彻底移除,取而代之的是 "元空间"(Metaspace) ,它使用的是本地内存(Native Memory),而不再是JVM堆内存。这样做的好处是元空间的大小只受限于本地内存,不容易出现OOM。
  3. 运行时常量池 (Runtime Constant Pool)

    • 它是方法区的一部分。用于存放编译期生成的各种字面量和符号引用。

【线程私有区域】

这些区域的生命周期与线程相同,随线程的创建而创建,随线程的销毁而销毁。

  1. Java虚拟机栈 (Java Virtual Machine Stack)

    • 每个线程都有一个独立的虚拟机栈。它用于存储栈帧(Stack Frame)
    • 每当一个方法被调用 时,JVM就会创建一个栈帧,并将其压入栈中。栈帧里存储了局部变量表、操作数栈、动态链接、方法出口等信息。
    • 当方法执行完毕后,对应的栈帧就会被弹出。我们常说的"栈内存"就是指这里。如果线程请求的栈深度大于虚拟机所允许的深度,会抛出StackOverflowError
  2. 本地方法栈 (Native Method Stack)

    • 与虚拟机栈非常相似,区别在于它为虚拟机使用到的 native方法(即由非Java语言实现的方法)服务。
  3. 程序计数器 (Program Counter Register)

    • 这是一块非常小的内存空间,可以看作是当前线程所执行的字节码的行号指示器
    • 字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
    • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
二、 Java内存模型 (JMM - The Concurrency Model)

如果说运行时数据区是物理层面的内存划分,那么Java内存模型(JMM)就是并发编程领域的抽象规范。它不是真实存在的内存结构,而是一套规则。

  • 目的 :JMM的核心目的是为了屏蔽各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果,从而实现"一次编写,到处运行"的承诺。
  • 核心内容 :它定义了线程和主内存之间的抽象关系。
    • 主内存 (Main Memory):所有线程共享的区域,存储了所有的实例字段、静态字段等。这可以粗略地对应于堆。
    • 工作内存 (Working Memory) :每个线程私有的区域,存储了该线程需要使用的变量在主内存中的副本拷贝。这可以粗略地对应于CPU的高速缓存。
  • 三大特性 :JMM围绕着在并发过程中如何处理原子性(Atomicity)、可见性(Visibility)和有序性(Ordering)这三个核心问题,定义了一系列的同步规则,比如volatilesynchronizedfinal的内存语义,以及著名的Happens-Before原则

总结一下

  • 运行时数据区 是JVM的内存蓝图,告诉我们数据都存放在哪里。
  • Java内存模型(JMM)是并发编程的行为准则,告诉我们线程间如何安全地共享和通信数据。

JVM内存模型里的堆和栈有什么区别?

面试官您好,堆和栈是JVM运行时数据区中两个最核心、但功能和特性截然不同的内存区域。它们的区别,我通常从以下几个维度来理解。

一个贯穿始终的比喻:快餐店的点餐与后厨

我们可以把一次程序运行想象成在一家快餐店点餐:

  • 栈(Stack) 就像是前台的点餐流程单
  • 堆(Heap) 就像是后厨的中央厨房
1. 核心用途与存储内容 (做什么?)
  • 栈 (点餐流程单)

    • 用途 :主要用于管理方法的调用 和存储基本数据类型变量 以及对象引用
    • 内容 :每当一个方法被调用,JVM就会创建一个"栈帧"(就像流程单上的一行),里面记录了这个方法的所有局部变量、操作数、方法出口等信息。
    • 比喻:点一个汉堡(调用一个方法),服务员就在流程单上记下一笔。
  • 堆 (中央厨房)

    • 用途 :是JVM中唯一 用来存储对象实例数组的地方。
    • 内容 :我们通过new关键字创建的所有对象,其实体都存放在堆中。栈上的那个对象引用,仅仅是一个指向堆中对象实体的"门牌号"或"地址"。
    • 比喻:流程单上记的"汉堡",只是一个名字(引用)。真正的汉堡实体(对象实例),是在后厨(堆)里制作和存放的。
2. 生命周期与管理方式 (谁管?怎么管?)
  • 栈 (自动化的流程单)

    • 生命周期 :非常规律和确定。一个方法调用开始,其对应的栈帧就被压入栈顶 ;方法执行结束,栈帧就自动弹出并销毁
    • 管理方式 :由编译器和JVM自动管理 ,无需我们程序员干预,也没有垃圾回收(GC)
  • 堆 (需要专人管理的厨房)

    • 生命周期 :不确定。一个对象的生命周期从new开始,直到没有任何引用指向它时才结束。
    • 管理方式 :由垃圾回收器(GC) 来自动管理。GC会定期地巡视堆,找出那些不再被使用的"无主"对象(垃圾),并回收它们占用的空间。
3. 空间大小与存取速度 (多大?多快?)
    • 空间 :通常较小 且大小是固定的 (可以通过-Xss参数设置)。
    • 速度非常快。因为栈的数据结构简单(LIFO),内存是连续的,CPU可以高效地进行压栈和弹栈操作。
    • 空间 :通常较大 且大小是可动态调整的 (可以通过-Xms-Xmx设置)。
    • 速度 :相对较慢。因为内存分配是不连续的,并且分配和回收的过程都比栈要复杂。
4. 线程共享性与可见性 (公有还是私有?)
  • 线程私有 。每个线程都有自己独立的虚拟机栈。一个线程不能访问另一个线程的栈空间,因此栈上的数据天然是线程安全的

  • 所有线程共享 。整个JVM进程只有一个堆。这意味着任何线程都可以通过引用访问堆上的同一个对象。这也正是多线程并发问题的根源所在,我们需要通过各种锁机制来保证对堆上共享对象访问的安全性。

5. 典型的异常

这两种内存区域如果使用不当,会分别导致两种最经典的JVM异常:

  • StackOverflowError (栈溢出) :通常是由于方法递归调用过深(流程单写得太长,超出了纸的范围),或者栈帧过大导致的。
  • OutOfMemoryError: Java heap space (堆溢出) :通常是由于创建了大量的对象实例,并且这些对象由于被持续引用而无法被GC回收(后厨的东西太多,放不下了),最终耗尽了堆内存。

通过这个全方位的对比,我们就能清晰地理解堆和栈在JVM中所扮演的不同角色和承担的不同职责了。

栈中存的到底是指针还是对象?

面试官您好,您这个问题问到了JVM内存管理的一个核心细节。最精确的回答是:栈中既不存指针,也不直接存对象,它存的是"基本类型的值"和"对象的引用"。

我们可以通过一个具体的代码例子和生活中的比喻来理解它。

1. 一个具体的代码例子

假设我们有下面这样一个方法:

java 复制代码
public void myMethod() {
    // 1. 基本数据类型
    int age = 30; 

    // 2. 对象引用类型
    String name = "Alice"; 
    
    // 3. 数组引用类型
    int[] scores = new int[3]; 
}

myMethod()被调用时,JVM会为它在当前线程的虚拟机栈 上创建一个栈帧。这个栈帧的"局部变量表"里会存放以下内容:

  • 对于 int age = 30;

    • age 是一个基本数据类型。JVM会直接在栈帧里为age分配一块空间,并将30 本身存放在这块空间里。
  • 对于 String name = "Alice";

    • name 是一个对象引用。JVM的处理分为两步:
      1. 在堆(Heap)中 创建一个String对象,其内容是 "Alice"。
      2. 在栈帧中name变量分配一块空间,这块空间里存放的不是"Alice"这个字符串本身 ,而是一个指向堆中那个String对象的内存地址 。这个地址,我们就称之为 "引用"(Reference)
  • 对于 int[] scores = new int[3];

    • scores 也是一个对象引用(在Java中,数组是对象)。
    • 处理方式与String类似:
      1. 在堆中创建一个可以容纳3个整数的数组对象。
      2. 在栈帧中scores变量分配空间,存放一个指向堆中那个数组对象的引用
2. 一个生动的比喻:酒店房间与房卡

我们可以把这个过程比喻成入住一家酒店:

  • 堆(Heap) :就像是酒店本身 ,里面有许多实实在在的房间(对象实例)
  • 栈(Stack) :就像是你手里的那张房卡(对象引用)
  • 基本类型 :就像是你口袋里的零钱(值),你直接就带在身上。

那么:

  • new String("Alice") :相当于酒店为你分配了一间房间(在堆上创建对象)
  • String name = ... :酒店前台给了你一张房卡(在栈上创建引用),这张房卡上有房间号,可以让你找到并打开那间房。
  • 你手里拿的,永远是房卡(引用),而不是整个房间(对象)。你想找房间里的东西,必须先通过房卡找到房间。
3. 总结:栈到底存了什么?
  • 基本数据类型 :直接存储本身。
  • 引用数据类型 :存储一个引用(内存地址) ,这个引用指向 中存放的对象实例

所以,严格来说,栈中存的既不是C++意义上的"指针"(虽然功能类似,但Java的引用是类型安全的,且由JVM管理),更不是对象本身。它存的是一个受JVM管理的、类型安全的、指向堆内存的"门牌号"------我们称之为"引用"。

堆分为哪几部分呢?

面试官您好,JVM的堆内存是垃圾回收器(GC)进行管理的主要区域,为了优化GC的效率 ,特别是为了实现分代回收(Generational GC) 的思想,HotSpot虚拟机通常会将堆划分为以下几个主要部分:

1. 新生代 (Young Generation / New Generation)

新生代是绝大多数新创建对象的"第一站"。它的主要特点是对象"朝生夕死",存活率低 。因此,新生代通常采用复制算法(Copying Algorithm) 进行垃圾回收,这种算法在对象存活率低的场景下效率非常高。

新生代内部又被细分为三个区域:

  • a. Eden区 (Eden Space)

    • 这是绝大多数新对象诞生的地方 。当我们new一个对象时,它首先会被分配在Eden区。
    • Eden区的空间是连续的,分配速度很快。
  • b. 两个Survivor区 (Survivor Space)

    • 通常被称为From区(S0)To区(S1)
    • 这两个区的大小是完全一样的,并且在任何时候,总有一个是空闲的
    • 它们的作用 :当Eden区进行垃圾回收(这个过程通常被称为Minor GCYoung GC )时,存活下来的对象会被复制到那个空闲的Survivor区(To区)。同时,另一个正在使用的Survivor区(From区)中还存活的对象,也会被一并复制到这个To区。
    • 复制完成后,Eden区和From区就被完全清空了。然后,From区和To区的角色会发生互换,等待下一次Minor GC。
2. 老年代 (Old Generation / Tenured Generation)

老年代用于存放那些生命周期较长 的对象,或者是一些大对象

  • 对象来源

    1. 从新生代晋升 :一个对象在新生代的Survivor区之间,每经历一次Minor GC并且存活下来,它的年龄(Age)就会加1。当这个年龄达到一个阈值(默认是15)时,它就会被"晋升"到老年代。
    2. 大对象直接分配 :如果一个对象非常大(比如一个巨大的数组),超过了JVM设定的阈值(可以通过-XX:PretenureSizeThreshold参数设置),为了避免它在新生代的Eden区和Survivor区之间频繁复制,JVM会选择将其直接分配在老年代
  • GC算法 :老年代的对象特点是存活率高 ,不适合用复制算法(因为需要复制的对象太多,空间浪费也大)。因此,老年代的垃圾回收(通常被称为Major GCFull GC )通常采用标记-清除(Mark-Sweep)标记-整理(Mark-Compact) 算法。

一个对象的"一生"

我们可以用一个故事来描绘一个普通对象的生命周期:

  1. 出生 :一个对象在Eden区诞生。
  2. 第一次考验 :经历了一次Minor GC,它幸运地活了下来,被移动到了Survivor的To区,年龄变为1。
  3. 颠沛流离 :在接下来的多次Minor GC中,它在S0区和S1区之间来回被复制,每次存活,年龄都会加1。
  4. 晋升 :当它的年龄终于达到15岁时,它被认为是一个"稳定"的对象,在下一次Minor GC后,它会被晋升到老年代
  5. 定居与终老:在老年代,它会"定居"下来,不再经历频繁的Minor GC。它会等待很久之后,发生Major GC或Full GC时,才会被检查是否还在被使用。如果最终不再被任何引用指向,它才会被回收,结束其一生。

这种分代的设计,使得JVM可以针对不同生命周期的对象,采用最高效的回收策略,从而大大提升了GC的整体性能。

程序计数器的作用,为什么是私有的?

面试官您好,程序计数器(Program Counter Register)是JVM运行时数据区中一块非常小但至关重要的内存区域。要理解它,我们可以从 "它是什么""为什么必须是线程私有" 这两个角度来看。

1. 程序计数器的作用 (What is it?)
  • 核心定义 :程序计数器可以看作是当前线程所执行的字节码的行号指示器
  • 它的工作 :在JVM中,字节码解释器就是通过读取和改变程序计数器的值,来确定下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能,都依赖于这个计数器来完成。
  • 一个重要的细节
    • 如果当前线程正在执行的是一个Java方法 ,那么程序计数器记录的就是正在执行的虚拟机字节码指令的地址
    • 如果当前线程正在执行的是一个 native方法 (本地方法),那么这个计数器的值是空(Undefined) 。因为native方法是由底层操作系统或其他语言实现的,不受JVM字节码解释器的控制。
2. 为什么程序计数器必须是线程私有的?(Why is it private?)

其根本原因就在于Java的多线程是通过CPU时间片轮转来实现的

  • 场景分析

    1. 现代操作系统都是多任务的,CPU会在多个线程之间高速地进行上下文切换
    2. 比如,线程A的当前时间片用完了,操作系统需要暂停它,然后切换到线程B去执行。
    3. 在暂停线程A之前,必须记录下它"刚才执行到哪里了"。这个"位置信息",就是由程序计数器来保存的。
    4. 当未来某个时刻,线程A重新获得CPU时间片时,它就需要恢复现场,从它上次被中断的地方继续执行。这时,它就会去查看自己的程序计数器,找到下一条应该执行的指令。
  • 结论

    • 因为每个线程的执行进度都是独立且不一样的 ,它们在任何时刻都可能被中断。为了在切换回来后能准确地恢复到正确的执行位置,每个线程都必须拥有自己专属的、互不干扰的程序计数器
    • 如果所有线程共享一个程序计数器,那么一个线程的执行就会覆盖掉另一个线程的进度记录,整个执行流程就会彻底混乱。
3. 一个独特的特性

值得一提的是,程序计数器是JVM运行时数据区中唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。因为它所占用的内存空间非常小且固定,几乎可以忽略不计。

总结一下 ,程序计数器就像是每个线程专属的 "书签",它忠实地记录着每个线程的阅读进度,确保了在并发执行和频繁切换的复杂环境下,每个线程都能准确无误地继续自己的执行流程。因此,它的"线程私有"特性,是实现多线程正确性的根本保障。

方法区中的方法的执行过程?

面试官您好,虽然方法本身的代码是存放在方法区 的,但一个方法的执行过程 ,其主战场却是在Java虚拟机栈(JVM Stack) 中。

整个过程,可以看作是一个栈帧(Stack Frame)在虚拟机栈中"入栈"和"出栈" 的旅程。

我通过一个简单的例子来描述这个动态过程:

java 复制代码
public class MethodExecution {
    public static void main(String[] args) {
        int result = add(3, 5); // 1. 调用add方法
        System.out.println(result);
    }

    public static int add(int a, int b) { // 2. add方法
        int sum = a + b;
        return sum; // 3. 返回
    }
}
第一步:方法调用与栈帧创建 (入栈)
  1. 解析 :当main线程执行到add(3, 5)这行代码时,JVM首先需要找到add方法在方法区的具体位置(如果之前没解析过的话)。
  2. 创建栈帧 :在调用add方法之前 ,JVM会在main线程的虚拟机栈中,为add方法创建一个新的栈帧 (我们称之为add-Frame),并将其压入栈顶
    • 此时,main方法对应的栈帧(main-Frame)就在add-Frame的下方。
    • 这个add-Frame就像一个专属的工作空间,它里面包含了:
      • 局部变量表 :用于存放add方法的参数ab(值分别为3和5),以及局部变量sum
      • 操作数栈:一个临时的计算区域,用于执行加法等操作。
      • 动态链接:指向运行时常量池中该方法所属类的引用。
      • 方法返回地址 :记录了add方法执行完毕后,应该回到main方法中的哪一行代码继续执行。
第二步:方法体执行
  1. 参数传递main-Frame中的操作数3和5,会被传递到add-Frame的局部变量表中,赋值给ab
  2. 字节码执行 :CPU开始执行add方法的字节码指令。
    • 将局部变量ab的值加载到add-Frame操作数栈上。
    • 执行加法指令,从操作数栈中弹出两个数相加,并将结果8再压入操作数栈。
    • 将操作数栈顶的结果8 ,存回到局部变量sum中。
    • 执行return指令,将局部变量sum的值再次加载到操作数栈顶,准备作为返回值。
第三步:方法返回与栈帧销毁 (出栈)

方法执行完毕,需要返回。返回分为两种情况:

  1. 正常返回 (Normal Return)

    • 像本例中,执行return sum;
    • add方法的栈帧会将返回值(8)传递给调用者(main方法)的栈帧 ,通常是压入main-Frame的操作数栈中。
    • 然后,add方法的栈帧会从虚拟机栈中被销毁(出栈)
    • 程序计数器会根据之前保存的方法返回地址 ,恢复到main方法中调用add的那一行,继续向后执行(比如将main-Frame操作数栈顶的8赋值给result变量)。
  2. 异常返回 (Abrupt Return)

    • 如果在add方法中发生了未被捕获的异常。
    • add方法的栈帧同样会被销毁(出栈),但它不会有任何返回值给调用者。
    • JVM会把这个异常对象抛给调用者main方法去处理。如果main方法也处理不了,这个异常会继续向上传播,直到最终导致线程终止。

总结一下 ,方法的执行过程,本质上是线程的虚拟机栈中,栈帧不断入栈和出栈的过程 。当前正在执行的方法,其对应的栈帧永远位于栈顶。这个清晰、高效的栈式结构,是Java方法能够实现有序调用和递归的基础。

方法区中还有哪些东西?

面试官您好,方法区是JVM运行时数据区中一个非常重要的线程共享 区域。正如《深入理解Java虚拟机》中所述,它主要用于存储已被虚拟机加载的元数据信息

我们可以把方法区想象成一个JVM的 "类型信息档案馆" ,当一个.class文件被加载进内存后,它的大部分"档案信息"都存放在这里。

这些信息主要可以分为以下几大类:

1. 类型信息 (Type Information)

这是方法区的核心。对于每一个被加载的类(或接口),JVM都会在方法区中存储其完整的元信息,包括:

  • 类的全限定名 (e.g., java.lang.String)。
  • 类的直接父类的全限定名 (e.g., java.lang.Object)。
  • 类的类型 (是类class还是接口interface)。
  • 类的访问修饰符 (public, abstract, final等)。
  • 类的直接实现接口的有序列表
  • 字段信息 (Field Info):每个字段的名称、类型、修饰符等。
  • 方法信息 (Method Info) :每个方法的名称、返回类型、参数列表、修饰符,以及最重要的------方法的字节码 (Bytecodes)
2. 运行时常量池 (Runtime Constant Pool)
  • 来源 :每个.class文件内部都有一个"常量池表(Constant Pool Table)",用于存放编译期生成的各种字面量符号引用 。当这个类被加载到JVM后,这个静态的常量池表就会被转换成方法区中的运行时常量池

  • 内容

    • 字面量 :比如文本字符串("Hello, World!")、final常量的值等。
    • 符号引用 (Symbolic References) :这是一种编译时的、用字符串表示的间接引用。它包括:
      • 类和接口的全限定名。
      • 字段的名称和描述符。
      • 方法的名称和描述符。
    • 在程序实际运行时,JVM会通过这些符号引用,去动态地查找并链接到真实的内存地址(这个过程叫动态链接)。
  • 动态性 :运行时常量池的一个重要特性是它是动态的。比如String.intern()方法,就可以在运行时将新的常量放入池中。

3. 静态变量 (Static Variables)
  • 也称为"类变量"。被static关键字修饰的字段,会存放在方法区中。
  • 这些变量与类直接关联,而不是与类的某个实例对象关联,因此被所有线程共享。
4. 即时编译器(JIT)编译后的代码缓存
  • 为了提升性能,HotSpot虚拟机会将频繁执行的"热点代码"(HotSpot Code)通过JIT编译器编译成本地机器码。
  • 这部分编译后的、高度优化的本地机器码,也会被缓存存放在方法区中,以便下次直接执行,无需再解释字节码。
方法区的演进:永久代与元空间

值得一提的是,方法区是一个逻辑上的概念,它的具体物理实现在不同JDK版本中是不同的:

  • JDK 7及以前 :HotSpot JVM使用永久代(Permanent Generation)来实现方法区。永久代是堆内存 的一部分,它有固定的大小上限,容易导致OutOfMemoryError: PermGen space
  • JDK 8及以后 :永久代被彻底移除,取而代之的是元空间(Metaspace) 。元空间使用的是本地内存(Native Memory),而不是JVM堆内存。这样做的好处是,元空间的大小只受限于操作系统的可用内存,极大地降低了因元数据过多而导致OOM的风险。

总结一下,方法区就像是JVM的"图书馆",里面存放着所有加载类的"户口本"(类型信息)、"字典"(运行时常量池)、"公共财产"(静态变量)以及"最优操作手册"(JIT编译后的代码)。它是Java程序能够运行起来的基础。

String保存在哪里呢?

情况一:通过字面量直接赋值 (String s = "abc";)
  • 存储位置 :当您像这样直接用双引号创建一个字符串时,这个字符串"abc"会被存放在一个特殊的内存区域,叫做字符串常量池(String Constant Pool)

  • 工作机制

    1. JVM在处理这行代码时,会先去字符串常量池里检查,看是否已经存在内容为"abc"的字符串
    2. 如果存在 ,JVM就不会创建新的对象,而是会直接将常量池中那个字符串的引用 返回,赋值给变量s
    3. 如果不存在 ,JVM才会在常量池中创建一个新的String对象,内容是"abc",然后将它的引用返回。
  • 特性 :这种方式创建的字符串,是共享的。例如:

    java 复制代码
    String s1 = "abc";
    String s2 = "abc";
    System.out.println(s1 == s2); // 输出: true

    这里的s1s2指向的是常量池中同一个对象。

情况二:通过new关键字创建 (String s = new String("abc");)
  • 存储位置 :这种方式的行为就和普通的Java对象一样了,它会涉及到两个内存区域。

  • 工作机制

    1. new String("abc")这行代码,首先,JVM还是会去检查字符串常量池,确保池中有一个"abc"的对象(如果没有就创建一个)。
    2. 然后,最关键的一步是,new关键字会在Java堆(Heap) 上,创建一个全新的String对象 。这个新的String对象内部的字符数组,会复制常量池中那个"abc"对象的数据。
    3. 最后,将堆上这个新对象的引用 返回给变量s
  • 特性 :这种方式总是在堆上创建一个新对象,即使字符串的内容已经存在于常量池中。

    java 复制代码
    String s1 = "abc"; // 在常量池
    String s2 = new String("abc"); // 在堆上
    System.out.println(s1 == s2); // 输出: false

    这里的s1s2指向的是两个完全不同的对象,一个在常量池,一个在堆。

字符串常量池的演进

值得一提的是,字符串常量池的物理位置在JDK版本中是有变迁的:

  • JDK 6及以前 :字符串常量池是方法区(永久代) 的一部分。
  • JDK 7 :字符串常量池被从方法区移到了Java堆中。
  • JDK 8及以后 :永久代被元空间取代,但字符串常量池仍然在Java堆中。

将常量池移到堆中,一个主要的好处是方便GC对常量池中的字符串进行回收。

intern() 方法的作用

String类还提供了一个intern()方法,它是一个与常量池交互的桥梁:

  • 当一个堆上的String对象(比如通过new创建的)调用intern()方法时,JVM会去字符串常量池里查找是否存在内容相同的字符串。
    • 如果存在 ,就返回常量池中那个字符串的引用
    • 如果不存在,就会将这个字符串的内容放入常量池,并返回新放入的那个引用。

总结一下

  • 直接用字面量 创建的String,对象在字符串常量池中。
  • new关键字 创建的String,对象主体在Java堆中。

理解这个区别,对于我们优化内存使用和正确判断字符串相等性(特别是用==时)至关重要。

引用类型有哪些?有什么区别?

面试官您好,Java中的引用类型,除了我们最常用的强引用,还提供了软、弱、虚三种不同强度的引用,它们的设计主要是为了让我们可以更灵活地与垃圾回收器(GC) 进行交互,从而实现更精细的内存管理。

我们可以把这四种引用的强度,比作一段关系的"牢固程度":

1. 强引用 (Strong Reference) ------ "生死相依"
  • 定义与特点 :这就是我们日常编程中最常见的引用形式,比如 Object obj = new Object();。只要一个对象还存在强引用指向它,那么垃圾回收器永远不会 回收这个对象,即使系统内存已经非常紧张,即将发生OutOfMemoryError
  • 生命周期 :直到这个强引用被显式地设置为null(比如obj = null;),或者超出了其作用域,它与对象之间的"强关联"才会断开。
  • 应用场景:所有常规对象的创建和使用。
2. 软引用 (Soft Reference) ------ "情有可原,可有可无"
  • 定义与特点 :用SoftReference类来包装对象。软引用关联的对象,是那些有用但并非必需的对象。
  • GC回收时机 :当系统内存即将发生溢出(OOM)之前,垃圾回收器会把这些软引用关联的对象给回收掉,以释放内存,尝试挽救系统。如果回收之后内存仍然不足,才会抛出OOM。
  • 应用场景 :非常适合用来实现高速缓存。比如,一个图片加载应用,可以将加载到内存的图片用软引用包装起来。内存充足时,图片可以一直保留在内存中,加快下次访问速度;内存紧张时,这些图片缓存可以被自动回收,而不会导致系统崩溃。
3. 弱引用 (Weak Reference) ------ "萍水相逢,一碰就忘"
  • 定义与特点 :用WeakReference类来包装对象。弱引用的强度比软引用更弱。
  • GC回收时机 :只要垃圾回收器开始工作,无论当前内存是否充足,被弱引用关联的对象都一定会被回收。也就是说,它只能"活"到下一次GC发生之前。
  • 应用场景
    • ThreadLocal的KeyThreadLocalMap中的Key就是对ThreadLocal对象的弱引用,这有助于在ThreadLocal对象本身被回收后,防止一部分内存泄漏。
    • 各种缓存和监听器注册 :在一些需要避免内存泄漏的缓存或回调注册场景中,使用弱引用可以确保当目标对象被回收后,相关的缓存条目或监听器也能被自动清理。最典型的就是WeakHashMap
4. 虚引用 (Phantom Reference) ------ "若有若无,形同虚设"
  • 定义与特点 :也叫"幻影引用",是所有引用类型中最弱的一种。它由PhantomReference类实现,并且必须和引用队列(ReferenceQueue)联合使用
  • 核心特性
    • 一个对象是否有虚引用,完全不影响其生命周期。就像没有这个引用一样,该被回收时就会被回收。
    • 我们永远无法通过虚引用来获取到对象实例phantomRef.get()方法永远返回null
  • 它的唯一作用 :当一个对象被GC确定要回收时,如果它有关联的虚引用,那么JVM会在真正回收其内存之前 ,将这个虚引用对象本身(而不是它引用的对象)放入与之关联的ReferenceQueue中。
  • 应用场景 :它主要用于跟踪对象被垃圾回收的活动 。最经典的应用就是管理堆外内存(Direct Memory) 。比如DirectByteBuffer,它在Java堆上只是一个很小的对象,但它在堆外分配了大量的本地内存。我们可以为这个DirectByteBuffer对象创建一个虚引用。当GC回收这个对象时,虚引用会入队。我们的后台清理线程可以监视这个队列,一旦发现有虚引用入队,就知道对应的堆外内存已经不再被使用,就可以安全地调用free()方法来释放这块本地内存了。

通过这四种不同强度的引用,Java赋予了开发者与GC协作的能力,让我们能够根据对象的生命周期和重要性,设计出更健壮、内存使用更高效的程序。

弱引用了解吗?举例说明在哪里可以用?

面试官您好,我了解弱引用。它是一种比软引用"更弱"的引用类型,其核心特点是:一个只被弱引用指向的对象,只要垃圾回收器开始工作,无论当前内存是否充足,它都一定会被回收。

弱引用提供了一种让我们能够"监视"一个对象生命周期,但又"不干涉"其被回收的方式。

弱引用最经典的应用案例剖析

在Java的API和各种框架中,弱引用有很多巧妙的应用。我举两个最著名的例子来说明它在哪里用,以及如何用:

案例一:ThreadLocal 中的内存泄漏"防线"

这是弱引用最广为人知的一个应用。

  • 背景ThreadLocal的内部,每个线程都持有一个ThreadLocalMap。这个Map的Entry (键值对)被设计为:
    • Key :是对ThreadLocal对象的弱引用 (WeakReference<ThreadLocal>)。
    • Value :是对我们实际存储的值的强引用
  • 为什么用弱引用?
    • 假设我们在代码中将一个ThreadLocal变量置为null了 (myThreadLocal = null;),这意味着我们不再需要它了。
    • 如果没有弱引用,而是强引用,那么即使myThreadLocal被置为null,只要这个线程还存活,ThreadLocalMap中的Entry就会一直强引用着这个ThreadLocal对象,导致它永远无法被回收。
    • 而使用了弱引用 后,当myThreadLocal在外部的强引用消失,下一次GC发生时,ThreadLocalMap中那个作为Key的ThreadLocal对象就会被自动回收 ,Entry的Key就变成了null
  • 作用 :这为清理Value创造了条件。虽然Value本身还是强引用,但ThreadLocal在调用get(), set()时,会顺便检查并清理掉这些Key为null的Entry。弱引用的使用,是ThreadLocal能够进行部分自我清理、防止内存泄漏的第一道防线。

案例二:WeakHashMap ------ 构建"会自动清理的缓存"

这是一个更直接体现弱引用价值的例子。

  • WeakHashMap是什么?
    • 它是一个键(Key)是弱引用的HashMap
  • 它是如何工作的?
    • 当我们向WeakHashMapput(key, value)时,这个key对象被弱引用所包裹。
    • 当外部不再有任何强引用指向这个key对象时,在下一次GC后,这个key对象就会被回收。
    • WeakHashMap内部有一个机制(通过ReferenceQueue),当它发现某个key被回收后,它会自动地将整个Entry(包括key和value)从Map中移除
  • 应用场景
    • 非常适合用来做缓存 。我们可以把缓存的键作为key,缓存的内容作为value
    • 好处 :当缓存的键(比如某个业务对象)在程序的其他地方不再被使用、被GC回收后,WeakHashMap中对应的这条缓存记录也会自动地、安全地被清理掉,我们完全不需要手动去维护缓存的过期和清理,从而完美地避免了因缓存引发的内存泄漏。
总结

弱引用的核心用途,就是构建一种非侵入式的、依赖于GC的关联关系 。它允许我们"依附"于一个对象,但又不会强行延长它的生命周期。这在实现缓存、元数据存储、监听器管理等需要避免内存泄漏的场景中,是非常有价值的工具。

内存泄漏和内存溢出的理解?

面试官您好,内存泄漏和内存溢出是Java开发者必须面对的两个核心内存问题。它们是两个不同但又紧密相关的概念。

我可以用一个 "水池注水" 的比喻来解释它们:

  • 内存(堆) :就像一个容量固定的水池
  • 创建新对象 :就像往水池里注入新的水
  • 垃圾回收(GC) :就像是水池的排水口,会自动排掉不再需要的水。
  • 内存泄漏 :就像是排水口被一些垃圾(无用的引用)堵住了一部分
  • 内存溢出 :就是水池最终被灌满了,水溢了出来
1. 内存泄漏 (Memory Leak) ------ "该走的不走"
  • 定义 :内存泄漏指的是,程序中一些已经不再被使用的对象,由于仍然存在着某个(通常是无意的)强引用链,导致垃圾回收器(GC)无法将它们回收
  • 本质:这些对象逻辑上已经是"垃圾"了,但GC不这么认为。它们就像"僵尸"一样,占着茅坑不拉屎,持续地、无效地消耗着宝贵的堆内存。
  • 后果 :一次小小的内存泄漏可能不会立即产生影响,但如果这种泄漏发生在频繁执行的代码路径上,日积月累,就会导致可用内存越来越少。
  • 常见原因
    • 长生命周期的对象持有短生命周期对象的引用 :最典型的就是静态集合类 。一个静态的HashMap,如果不手动remove,它里面存放的对象的生命周期就和整个应用程序一样长,即使这些对象早就不需要了。
    • 资源未关闭 :比如数据库连接、网络连接、文件IO流等,如果没有在finally块中正确关闭,它们持有的底层资源和缓冲区内存就无法被释放。
    • 监听器和回调未注销:一个对象注册了监听器,但自身销毁前没有去注销,导致被监听的目标对象一直持有它的引用。
    • ThreadLocal使用不当 :没有在finally中调用remove()方法,导致在线程池场景下,Value对象无法被回收。
2. 内存溢出 (OutOfMemoryError, OOM) ------ "想来的来不了"
  • 定义 :内存溢出是一个结果 ,是一个错误(Error) 。它指的是,当程序需要申请更多内存时(比如new一个新对象),而JVM发现堆内存已经耗尽,并且经过GC后也无法腾出足够的空间,最终只能抛出OutOfMemoryError,导致应用程序崩溃。
  • 常见原因
    • 内存泄漏的累积:这是最隐蔽、最常见的原因。持续的内存泄漏最终会"吃光"所有可用内存,导致OOM。
    • 瞬时创建大量对象:程序在某个时刻需要处理大量数据,一次性加载了海量对象到内存中,直接超出了堆的上限。比如,一次性从数据库查询一百万条记录并映射成对象。
    • 堆空间设置不合理 :JVM启动时,通过-Xmx参数设置的堆最大值,对于应用的实际需求来说太小了。
    • StackOverflowError :虽然这也是OOM的一种,但它特指栈内存 溢出,通常是由于无限递归或方法调用链过深导致的。
3. 关系总结
  • 内存泄漏是原因,内存溢出是结果
  • 持续的、未被发现的内存泄漏,最终必然会导致内存溢出
  • 但是,发生内存溢出,并不一定是因为内存泄漏。也可能是因为数据量确实太大,或者JVM参数配置不当。
4. 如何排查?

在实践中,排查这类问题,我会使用专业的内存分析工具:

  1. 通过JVM参数(-XX:+HeapDumpOnOutOfMemoryError)让JVM在发生OOM时,自动生成一个堆转储快照(Heap Dump)文件
  2. 使用内存分析工具 (如 MAT (Memory Analyzer Tool)、JProfiler等)来打开和分析这个dump文件。
  3. 在MAT中,可以查看支配树(Dominator Tree)和查找泄漏嫌疑(Leak Suspects),工具会自动帮我们分析哪些对象占用了大量内存,以及是什么样的引用链导致它们无法被回收,从而快速定位到问题的根源。

JVM内存结构有哪几种内存溢出的情况?

面试官您好,JVM的内存结构在不同区域都可能发生内存溢出,这通常意味着程序申请内存超出了JVM所能管理的上限。我主要熟悉以下四种最常见的内存溢出情况:

1. 堆内存溢出 (Heap OOM)
  • 异常信息java.lang.OutOfMemoryError: Java heap space

  • 原因分析 :这是最常见的一种OOM。正如您所说,根本原因是在堆中无法为新创建的对象分配足够的空间。这通常由两种情况导致:

    1. 内存泄漏(Memory Leak):程序中存在生命周期过长的对象(如静态集合),它们持有了不再使用的对象的引用,导致GC无法回收,可用内存越来越少。
    2. 内存确实不够用:程序需要处理的数据量确实非常大,比如一次性从数据库查询了数百万条记录并加载到内存中,直接超出了堆的容量上限。
  • 代码示例

    java 复制代码
    // 模拟内存确实不够用
    List<byte[]> list = new ArrayList<>();
    while (true) {
        // 不断创建大对象,直到耗尽堆内存
        list.add(new byte[1024 * 1024]); // 1MB
    }
  • 解决方案

    1. 分析Heap Dump:使用MAT等工具分析OOM时生成的堆转储文件,查看是哪些对象占用了大量内存,并检查其引用链,判断是否存在内存泄漏。
    2. 优化代码:如果是数据量过大,需要优化代码逻辑,比如使用流式处理、分批加载等方式,避免一次性加载所有数据。
    3. 调整JVM参数 :如果确认业务上需要这么多内存,可以通过增大-Xmx参数来调高堆的最大值。
2. 虚拟机栈和本地方法栈溢出 (Stack OOM)
  • 异常信息 :通常是 java.lang.StackOverflowError,在极少数无法扩展栈的情况下可能是OutOfMemoryError

  • 原因分析 :每个线程都有自己的虚拟机栈,用于存放方法调用的栈帧。栈溢出通常不是因为内存"不够大",而是因为栈的深度超过了限制

    • 最常见的原因就是无限递归方法调用链过深
  • 代码示例

    java 复制代码
    public class StackOverflowTest {
        public static void recursiveCall() {
            recursiveCall(); // 无限递归
        }
        public static void main(String[] args) {
            recursiveCall();
        }
    }
  • 解决方案

    1. 检查代码逻辑:仔细检查代码,找出导致无限递归或过深调用的地方并修复它。这是最根本的解决办法。
    2. 调整栈大小 :如果确认业务逻辑需要很深的调用栈,可以通过-Xss参数来增大每个线程的栈空间大小,但这治标不治本。
3. 元空间溢出 (Metaspace OOM)
  • 异常信息java.lang.OutOfMemoryError: Metaspace

  • 原因分析 :元空间(在JDK 8之前是永久代)主要存储类的元数据信息。元空间溢出意味着加载的类太多了

    • 常见原因包括:系统本身非常庞大,加载了大量的类和第三方jar包;或者在运行时通过动态代理、反射、CGLIB等技术,动态生成了大量的类,但这些类又没能被及时卸载。
  • 代码示例

    java 复制代码
    // 使用CGLIB等字节码技术不断生成新类
    while (true) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(MyClass.class);
        enhancer.setUseCache(false);
        enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> 
            proxy.invokeSuper(obj, args));
        enhancer.create();
    }
  • 解决方案

    1. 排查类加载情况:检查是否有动态类生成相关的库(如CGLIB)被滥用。
    2. 优化依赖:精简项目依赖,移除不必要的jar包。
    3. 调整JVM参数 :通过增大-XX:MaxMetaspaceSize参数来调高元空间的最大值。
4. 直接内存溢出 (Direct Memory OOM)
  • 异常信息java.lang.OutOfMemoryError: Direct buffer memory

  • 原因分析 :这是由于使用了NIO(New I/O) 中的ByteBuffer.allocateDirect()方法,在堆外(本地内存) 分配了大量内存,而这部分内存又没能被及时回收。

    • 直接内存的回收,依赖于与之关联的DirectByteBuffer对象被GC回收时,触发一个清理机制(通过虚引用和Cleaner)。如果堆内存迟迟没有触发GC,那么堆外的直接内存就可能一直得不到释放,最终耗尽。
  • 代码示例

    java 复制代码
    // 不断分配直接内存,但不触发GC
    List<ByteBuffer> buffers = new ArrayList<>();
    while (true) {
        buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 1MB
    }
  • 解决方案

    1. 检查NIO代码:确保合理使用直接内存,并在不需要时及时清理。
    2. 适时手动GC :在一些极端情况下,如果确认直接内存压力大,可以考虑在代码中调用System.gc()来"建议"JVM进行一次Full GC,但这通常不被推荐。
    3. 调整JVM参数 :通过-XX:MaxDirectMemorySize参数来明确指定直接内存的最大容量。

通过对这几种OOM的理解和分析,我们可以在遇到问题时,根据不同的异常信息,快速地定位到可能的原因,并采取相应的解决措施。

有具体的内存泄漏和内存溢出的例子么请举例及解决方案?

案例一:静态集合类导致的内存泄漏

这是最常见、也最容易被忽视的一种内存泄漏。

1. 问题场景代码

假设我们有一个需求,需要临时缓存一些用户信息,但开发人员错误地使用了一个静态的HashMap来存储。

java 复制代码
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

// 模拟一个用户服务
class UserService {
    // 【问题根源】使用了一个静态的Map来缓存用户对象
    private static Map<String, User> userCache = new HashMap<>();

    public void cacheUser(User user) {
        if (!userCache.containsKey(user.getId())) {
            userCache.put(user.getId(), user);
            System.out.println("缓存用户: " + user.getId() + ", 当前缓存大小: " + userCache.size());
        }
    }
    // 缺少一个移除缓存的方法!
}

// 用户对象
class User {
    private String id;
    private String name;
    // ... 构造函数, getter/setter ...
    public User(String id, String name) { this.id = id; this.name = name; }
    public String getId() { return id; }
}

// 模拟Web请求不断调用
public class StaticLeakExample {
    public static void main(String[] args) {
        UserService userService = new UserService();
        while (true) {
            // 模拟每次请求都创建一个新的User对象并缓存
            String userId = UUID.randomUUID().toString();
            User newUser = new User(userId, "User-" + userId);
            userService.cacheUser(newUser);

            // 为了不让程序瞬间OOM,稍微 sleep 一下
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
2. 泄漏原因分析
  1. 静态变量的生命周期userCache是一个static变量,它的生命周期和整个UserService类的生命周期一样长,通常也就是整个应用程序的运行时间。
  2. 持续的强引用while (true)循环不断地创建新的User对象并调用cacheUser方法。每调用一次,这个新的User对象就被put进了静态的userCache中。
  3. 无法被GC回收userCache这个Map一直强引用 着所有被放进去的User对象。即使这些User对象在业务逻辑上早就不需要了,但只要userCache还引用着它们,垃圾回收器就永远不会 回收这些User对象。
  4. 最终结果 :随着时间推移,userCache越来越大,占用的堆内存越来越多,最终耗尽所有堆内存,抛出 java.lang.OutOfMemoryError: Java heap space
3. 解决方案
  1. 明确移除(治标) :最直接的办法是,在确定不再需要某个缓存对象时,手动从userCache中调用remove()方法将其移除,切断强引用。但这依赖于开发者必须记得去调用,容易遗漏。

  2. 使用弱引用(治本) :这是一个更优雅、更自动化的解决方案。我们可以使用WeakHashMap来替代HashMap

    java 复制代码
    // 解决方案:使用WeakHashMap
    private static Map<String, User> userCache = new WeakHashMap<>();
    • WeakHashMap的特性 :它的键(Key)是弱引用。当一个User对象在程序的其他地方不再有任何强引用指向它时(比如,处理完一个Web请求,相关的User对象都变成了垃圾),即使它还存在于WeakHashMap中,GC也会将它回收。WeakHashMap在检测到Key被回收后,会自动地将整个键值对从Map中移除。
    • 这样,缓存的生命周期就和它所缓存的对象的生命周期自动绑定了,完美地避免了内存泄漏。
  3. 使用专业的缓存框架(最佳实践) :在生产环境中,我们不应该手写缓存。应该使用专业的缓存框架,如Guava Cache , Caffeine , 或 Ehcache。这些框架不仅内置了基于弱引用、软引用的自动清理机制,还提供了更丰富的功能,如基于大小的淘汰、基于时间的过期、统计等。

案例二:ThreadLocal使用不当导致的内存泄漏

这个案例在线程池环境下尤其常见。

1. 问题场景代码
java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalLeakExample {
    // 创建一个ThreadLocal来存储大对象
    static ThreadLocal<byte[]> localVariable = new ThreadLocal<>();

    public static void main(String[] args) {
        // 使用固定大小的线程池
        ExecutorService executor = Executors.newFixedThreadPool(1);

        for (int i = 0; i < 100; i++) {
            executor.submit(() -> {
                // 【问题根源】为ThreadLocal设置了一个大对象
                localVariable.set(new byte[1024 * 1024 * 5]); // 5MB
                System.out.println("线程 " + Thread.currentThread().getName() + " 设置了值");
                
                // 【关键问题】任务执行完毕后,没有调用remove()方法!
                // localVariable.remove(); // 正确的做法应该是加上这一行
            });

            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // ...
    }
}
2. 泄漏原因分析
  1. 线程池与线程复用newFixedThreadPool(1)创建了一个只有一个线程的线程池。这意味着,所有100个任务,都是由同一个线程来轮流执行的。
  2. ThreadLocal的存储原理ThreadLocal的值实际上是存储在Thread对象自身的ThreadLocalMap中的。
  3. 引用链分析
    • 当第一个任务执行时,它在线程T1ThreadLocalMap中放入了一个5MB的字节数组。
    • 任务结束后,localVariable这个ThreadLocal对象可能会因为方法结束而被回收(它的Key是弱引用)。
    • 但是,线程T1并不会被销毁,它会被归还给线程池,等待下一个任务。
    • 此时,一条强引用链 依然存在:线程池 -> 线程T1 -> T1.threadLocals(ThreadLocalMap) -> Entry -> Value(5MB的byte[])
    • 这个5MB的数组就因为这条强引用链而无法被GC回收
  4. 最终结果 :当后续的任务在这个线程上执行,又调用localVariable.set()时,它会覆盖掉旧的值,但如果后续任务不再使用这个ThreadLocal,那么最后一次设置的那个5MB的数组就会永久地 留在这个线程里,直到线程池被关闭。如果线程池很大,或者ThreadLocal存储的对象更多,就会慢慢地耗尽内存,导致OOM。
3. 解决方案

解决方案非常简单,但必须强制遵守:

  • 养成在finally块中调用remove()的习惯

    java 复制代码
    executor.submit(() -> {
        localVariable.set(new byte[1024 * 1024 * 5]);
        try {
            // ... 执行业务逻辑 ...
            System.out.println("线程 " + Thread.currentThread().getName() + " 设置了值");
        } finally {
            // 确保在任务结束时,无论正常还是异常,都清理ThreadLocal的值
            localVariable.remove();
            System.out.println("线程 " + Thread.currentThread().getName() + " 清理了值");
        }
    });
  • 调用remove()方法会彻底地将ThreadLocalMap中对应的Entry移除 ,从而切断整个引用链,让Value对象可以被正常地垃圾回收。这是使用ThreadLocal时必须遵守的铁律。

相关推荐
lpfasd1236 分钟前
状态模式(State Pattern)
java·设计模式·状态模式
代码老y10 分钟前
前端开发中的可访问性设计:让互联网更包容
java·服务器·前端·数据库
jakeswang12 分钟前
Java 项目中实现统一的 追踪ID,traceId实现分布式系统追踪
java·后端·架构
猛犸MAMMOTH13 分钟前
Python打卡第53天
开发语言·python·深度学习
寒山李白17 分钟前
Java 传输较大数据的相关问题解析和面试问答
java·开发语言·面试·传输
白总Server29 分钟前
Golang dig框架与GraphQL的完美结合
java·大数据·前端·javascript·后端·go·graphql
lightgis1 小时前
个人支出智能分析系统
java
春生野草1 小时前
MyBatis中关于缓存的理解
java·缓存·mybatis
道剑剑非道1 小时前
QT开发技术【ffmpeg EVideo录屏软件 一】
开发语言·qt·ffmpeg
oioihoii1 小时前
C++11 Generalized(non-trivial) Unions:从入门到精通
java·开发语言·c++