JVM的垃圾回收机制

哪些内存需要回收

我们都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区

其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法。

引用计数算法

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

代码示例(为什么不能处理相互引用的问题):

复制代码
public class ReferenceCount {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    private byte[] bigSize = new byte[2 * _1MB];
 
    public static void main(String[] args) {
        ReferenceCount objA = new ReferenceCount();
        ReferenceCount objB = new ReferenceCount();
        objA.instance = objB;
        objB.instance = objA;
 
        objA = null;
        objB = null;
 
        System.gc();
    }
}
复制代码

在objA中引用了objB,又在objB中引用objB,之后对两个实例进行参数传递null的时候,为什么计数器的值会不为0呢?毕竟在程序中看起来两个对象好像都只被引用了一次,传递null的时候执行减一,不应该是0吗?

实际上这里的坑就在于这个类中的属性可以接收一个对象,在代码:

objA.instance = objB;
objB.instance = objA;

执行完的时候,objA中的instance存放objB,但是注意:这里面存放的objB,里面也有一个instance,该instance存放的是objA,objA的instance存放的又是objB……如此往复,不断套娃,这样一来,引用计数器中的数肯定不是1了,因为objA和objB都被引用了无数次(说无数次不太合适,毕竟有内存限制,总之远大于1就是了),即使在传递null执行引用计数器减一,结果也不是0,所以没有办法进行回收。

可达性分析算法

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表);
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(Native方法)引用的对象。

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

现在问题来了,可达性分析算法会不会出现对象间循环引用问题呢?答案是肯定的,那就是不会出现对象间循环引用问题。GC Root在对象图之外,是特别定义的“起点”,不可能被对象图内的对象所引用。

对象生存还是死亡(To Die Or Not To Die)

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。程序中可以通过覆盖finalize()来一场"惊心动魄"的自我拯救过程,但是,这只有一次机会。

复制代码
/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 */
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isApve() {
        System.out.println("yes, i am still apve :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isApve();
        } else {
            System.out.println("no, i am dead :(");
        }

        //下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isApve();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}
复制代码

运行结果:

finalize mehtod executed!
yes, i am still apve :)
no, i am dead :(

Java中的引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。

在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

强引用

在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。 

软引用

用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

弱引用

也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。比如 threadlocal。

虚引用

也叫幽灵引用或幻影引用(名字真会取,很魔幻的样子),是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。

软应用示例:

复制代码
import java.lang.ref.SoftReference;

class Node {
    public String msg = "";
}

public class Hello {
    public static void main(String[] args) {
        Node node1 = new Node(); // 强引用
        node1.msg = "node1";
        SoftReference<Node> node2 = new SoftReference<Node>(node1); // 软引用
        node2.get().msg = "node2";

        System.out.println(node1.msg);
        System.out.println(node2.get().msg);
    }
}
复制代码

输出结果为:

node2
node2

对象死亡(被回收)前的最后一次挣扎

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;

第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

第二次标记成功的对象将真的会被回收,如果对象在finalize()方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

方法区如何判断是否需要回收

方法区存储内容是否需要回收的判断可就不一样了。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

常见的垃圾回收算法

标记清除算法(Mark-Sweep)

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。

标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象清除阶段就是回收被标记的对象所占用的空间

从图中可以很容易看出标记-清除算法实现起来比较容易,并且在存活对象比较多的情况下极为高效,但是有一个比较严重的问题就是直接回收不存活的对象,不做任何移动,容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作

复制算法(Copying)

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块每次只使用其中的一块。当这一块的内存用完了,就还存活着的对象复制到另外一块上面然后再把已使用的内存空间一次性清理掉,这样一来就不容易出现内存碎片的问题。

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半

很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于copying算法的垃圾 收集就从根集合(GC Roots)中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。

标记-整理算法(Mark-compact)

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动(记住是完成标记之后,先不清理,先移动再清理回收对象),然后清理掉端边界以外的内存。

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:

分代收集算法 Generational Collection

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间

而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。

元空间有注意有两个参数:

  • MetaspaceSize :初始化元空间大小,控制发生GC阈值。
  • MaxMetaspaceSize : 限制元空间大小上限,防止异常占用过多物理内存。

移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。

有了元空间就再也不会出现永久代OOM问题了!(但是元空间依然会出现内存溢出)

年轻代的回收算法 (以Copying为主)

(1) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

(2) 新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空(为什么保持survivor1为空,答案:为了让eden和survivor0 交换存活对象), 如此往复。当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC。

(3) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。

(4) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

年老代的回收算法(以Mark-Compact为主)

(1) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

(2) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

持久代(也就是方法区)的回收算法

用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

新生代和老年代的区别

所谓的新生代和老年代是针对于分代收集算法来定义的,新生代又分为Eden和Survivor两个区,加上老年代就这三个区。数据会首先分配到Eden区当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)。当Eden没有足够空间的时候就会触发jvm发起一次Minor GC。如果对象经过一次Minor GC还存活,并且又能被Survivor空间接受,那么将被移动到Survivor空间当中,并将其年龄设为1。对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代 中了,当然晋升老年代的年龄是可以设置的。如果老年代满了就执行Full GC,因为Full GC不经常执行,因此采用了 Mark-Compact算法清理。

其实新生代和老年代就是针对于对象做分区存储,更便于回收。

为什么分代

将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。

为什么survivor分为两块相等大小的幸存空间

主要为了解决碎片化。如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发GC。

常见的垃圾收集器

在介绍垃圾收集器之前,首先介绍以下这几个概念:

(1) stop-the-world

它是指 JVM 由于要执行 GC 而停止了应用程序的执行,并且这种情形会在任何一种 GC 算法中发生。当 stop-the-world 发生时,除了 GC 的线程以外,其他的线程均处于等待的状态,直到 GC 任务完成。实际上,很多 GC 优化都是通过减少 stop-the-world 的时间来提高程序的性能。

(2) safe-point

程序执行时并非在所有地方都能停顿下来开始 GC,只有在某些特定的位置才可以,这些特定的位置被称为安全点(safe-point)。在使用 GC roots 分析可达性时,引用关系不会发生改变的点就是安全点,常用的安全点有如下几种:

  • 方法调用
  • 循环跳转
  • 异常跳转

(3)  JVM 运行模式

JVM 运行模式有如下这两种:

  • Client:启动快,进入稳定期后运行速度不如 Server 快。
  • Server:启动慢,进入稳定期后运行速度优于 Client . Server 模式采用的是重量级的虚拟机,对程序会进行更多的优化。

查看当前 JVM 运行模式的指令:java -version

(4) 最大垃圾回收暂停

指定垃圾回收时的最长暂停时间,通过-XX:MaxGCPauseMillis=<N>指定。<N>为毫秒,如果指定了此值的话,堆大小和垃圾回收相关参数会进行调整以达到指定值。设定此值可能会减少应用的吞吐量。

(5) 吞吐量

吞吐量为垃圾回收时间与非垃圾回收时间的比值,通过-XX:GCTimeRatio=<N>来设定,公式为1/(1+N)。例如,-XX:GCTimeRatio=19时,表示5%的时间用于垃圾回收。默认情况为99,即1%的时间用于垃圾回收。

以下是7个常见的垃圾收集器:

图的上下半区分别指的是,新生代和老年代。其中连线的意思是,这些垃圾收集器之间可以配合使用。

Serial 年轻代 串行回收

单CPU效率最高,虚拟机是Client模式的默认垃圾回收器。Serial回收器使用单线程进行垃圾回收,使用复制算法。在 HotSpot 虚拟机中,使用-XX:+UseSerialGC 参数可以指定使用Serial(新生代串行收集器)+ Serial Old(老年代串行收集器)。

PS(Parallel Scavenge)年轻代 并行回收

新生代并行回收收集器也是使用复制算法的收集器。从表面上看,它和并行收集器一样都是多线程、独占式的收集器。但是,并行回收收集器有一个重要的特点:它非常关注系统的吞吐量。
在启动参数中指定-XX:+UseParallelGC,会使用Parallel Scavenge(新生代并行回收收集器) + SerialOld的回收器组合
如果使用-XX:+UseParallelOldGC,表示Parallel Scavenge(新生代并行回收收集器)+ Parallel Old(并行回收收集器)

ParNew 年轻代 配合CMS的并行回收

Serial回收器的多线程版本,只能用于新生代。使用复制算法,多线程并行工作。在多CPU主机上的性能高于Serial,单CPU主机上的性能低于Serial。
如果使用-XX:+UseParNewGC,表示ParNew(并行收集器)+ Serial Old(串行收集器)

 

SerialOld老年代 串行回收

Serial收集器的老年版本,独占式,单线程,使用的是标记--整理算法,这个收集器的目的也是用于Client模式下的虚拟机使用。

ParallelOld老年代 并行回收

是Parallel Scavenge收集器的老年版本,使用多线程和标记整理算法,注重吞吐量优先,在注重吞吐量和CPU资源敏感的场合,都可以考虑Parallel Scavenge加Parallel Old收集器。

ConcurrentMarkSweep 老年代 并发的

垃圾回收和应用程序同时运行,降低STW的时间(200ms)。

算法:三色标记 + Incremental Update

(1) 初始标记:Stop the world,只标记GC Roots能直接关联到的对象。

(2) 并发标记:GC Roots Tracing

(3) 重新标记:Stop the world,修正并发标记期间,因用户程序继续运行导致标记产生变动的那一部分对象的标记记录。

(4) 并发清除:并发清除可以和用户线程一起运行,所以总体上停顿的时间非常短。

但是CMS收集器有三个显著缺点:

  • 对CPU资源敏感。
  • 无法处理浮动垃圾。
  • 收集结束后会产生大量碎片。

G1(Garbage First)垃圾收集器

G1(Garbage First)垃圾收集器是当今垃圾回收技术最前沿的成果之一。早在JDK7就已加入JVM的收集器大家庭中,成为HotSpot重点发展的垃圾回收技术。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源。

G1垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器。虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。事实上,G1收集与以上三组收集器有很大不同:

(1) G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;

(2) G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);

(3) G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;

(4) G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

开启选项:-XX:+UseG1GC 

GC是什么时候触发的

Minor GC

一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

Full GC

对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:

  • 年老代(Tenured)被写满;
  • 持久代(Perm)被写满;
  • System.gc()被显示调用;
  • 上一次GC之后Heap的各域分配策略动态变化;

其他

CMS收集器和G1收集器优缺点

图解 CMS 垃圾回收机制原理

G1 垃圾收集器入门

 

posted @   残城碎梦  阅读(188)  评论(0编辑  收藏  举报
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示