当前位置: 首页 > article >正文

JVM学习(九):堆

目录

一、堆(Heap)的概述

二、堆内存 

2.1 内存细分 

2.2 常用的相关JVM参数 

2.2.1 堆空间大小设置 

2.2.2 老年代和新生代比例设置

2.2.3 年轻代内的比例设置

2.2.4 利用VisualVM直观查看

2.2.5 关闭自适应的内存分配策略

三、对象分配过程 

3.1 对象分配的一般过程 

3.2 对象分配的全过程        

四、Young GC、Major GC和 Full GC

4.1 概念区分 

4.2 年轻代Gc(Minor GC)触发机制

4.3 老年代GC (Major GC/Full GC)触发机制

五、内存分配机制 

5.1 一般机制 

5.2 直接进入老年代的情况

5.3 TLAB(Thread Local Allocation Buffer)

5.3.1 为什么要有TLAB 

5.3.2 什么是TLAB

5.3.3 TLAB说明 

5.4 空间分配担保:-XX:HandlePromotionFailure

六、堆空间常用参数设置总结

七、逃逸分析 

7.1 堆是分配对象存储的唯一选择吗?

7.2 什么叫逃逸? 

7.3 逃逸分析概述

7.4 代码优化 

7.4.1 栈上分配

7.4.2 同步省略

7.4.3 标量替换


一、堆(Heap)的概述

        一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。

        Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。同时,堆内存的大小是可以调节的。《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

        所有的线程共享Java堆,但在堆里还可以划分线程私有的缓冲区 ( Thread Local Allocation Buffer, TLAB)

        《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上(The heap is the run-time data area from which memory for all class instances and arrays is allocated )。其实从实际使用角度看,“几乎”所有的对象实例都在这里分配内存。数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

        为了说明对象与栈和堆的关系,我们用一段代码:

public class SimpleHeap {
    //属性、成员变量
    private int id;

    public SimpleHeap(int id) {
        this.id = id;
    }

    public void show() {
        System.out.println("My ID is " + id);
    }

    public static void main(String[] args) {
        SimpleHeap sl = new SimpleHeap(1);
        SimpleHeap s2 = new SimpleHeap(2);
    }
}

        在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除(因为垃圾收集的时候才会去扫描垃圾)。堆是GC ( Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

二、堆内存 

2.1 内存细分 

        现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

        Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space        新生区        Young/New        又被划分为Eden区和survivor区
  • Tenure generation space        养老区         Old/ Tenure
  • Permanent Space                   永久区         Perm

        Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space        新生区        Young/New        又被划分为Eden区和Survivor区
  • Tenure generation space        养老区         Old/Tenure
  • Meta Space                             元空间         Meta

2.2 常用的相关JVM参数 

2.2.1 堆空间大小设置 

        Java堆区用于存储Java对象实例,堆的大小在JVM启动时就已经设定好了,可以通过选项"-Xmx"和"-Xms"来进行设置。

  • -Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize
  • -Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize

        一旦堆区中的内存大小超过“-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。

        通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

        默认情况下,初始内存大小 = 物理电脑内存大小 / 64;最大内存大小 = 物理电脑内存大小 / 4

        下面来实践一下,写一段代码看看堆内存大小: 

public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");

        System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
        System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
}

        运行结果:

-Xms : 15M
-Xmx : 247M
系统内存大小为:0.9375G
系统内存大小为:0.96484375G

         修改一下:

-Xms : 580M
-Xmx : 580M
系统内存大小为:36.25G
系统内存大小为:2.265625G

2.2.2 老年代和新生代比例设置

        存储在VM中的Java对象可以被划分为两类:一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速;另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。

        Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)。其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1空间(有时也叫做from区、to区)。

        配置新生代与老年代在堆结构的占比使用 -XX:NewRatio

        默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。

2.2.3 年轻代内的比例设置

        在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1,开发人员可以通过选项 -XX:SurvivorRatio 调整这个空间比例。比如-XX:SurvivorRatio=8。

        几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。IBM公司的专门研究表明,新生代中80% 的对象都是“朝生夕死”的。

        可以使用选项"-Xmn"设置新生代最大内存大小。这个参数一般使用默认值就可以了。

2.2.4 利用VisualVM直观查看

         我们可以直观地看一下这些比例。随便运行一段不结束的程序:

public class EdenSurvivorTest {
    public static void main(String[] args) {
        System.out.println("我只是来打个酱油~");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

         我们设置堆的大小为600M:

        打开visualVM:

 

         我们将堆区设置为600M,默认-XX:NewRatio=2,新生代占1/3,即200M;老年代占2/3,即400M。默认-XX:SurvivorRatio=8,Eden区占8/10,即160M,两个Survivor区各占1/10,即20M。

 2.2.5 关闭自适应的内存分配策略

        JVM默认是开启自适应内存分配策略的,也就是说,新生代的8:1:1 并不会被严格遵守,而是会由虚拟机动态地调整。我们可以使用 -XX:-UseAdaptiveSizePolicy 来关闭自适应的内存分配策略。

三、对象分配过程 

        为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中等生内存碎片。

3.1 对象分配的一般过程 

        下面我们来看看对象分配的过程: 

        new的对象先放伊甸园区。此区有大小限制,当伊甸园的空间填满时,程序又需要创建对象,此时JVM的垃圾回收器将对伊甸园区进行对象进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁,然后将伊甸园中的剩余对象移动到幸存者0区。幸存者区中会有年龄计数器,每活过一次GC,年龄就会+1。

         如果再次触发垃圾回收,伊甸园区幸存的对象和幸存者0区中依然没有被回收的对象,就会放到幸存者1区。

         如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。当幸存者区中的对象超过15岁时,就会被分配到老年区。进入老年代的年龄是可以设置的,设置参数: 

-XX:MaxTenuringThreshold=<N> 。

        在养老区,相对悠闲。当养老区内存不足时,再次触发GC(Major GC),进行养老区的内
存清理。

        若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常。

3.2 对象分配的全过程        

        这里要注意的是,当new出特别大的对象(Eden放不下)时,首先要进行一次YGC,还是放不下,就直接放到老年代了;如果Servivor区满了,而Eden区没满,此时不会触发YGC。多余的对象会直接放到老年代。 

        GC频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。  

四、Young GC、Major GC和 Full GC

4.1 概念区分 

        JVM在进行GC时,并非每次都对三个内存(新生代、老年代;方法区)区域一起回收,大部分时候回收的都是新生代。

        针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。

                        目前,只有CMS GC会有单独收集老年代的行为。

                        注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。

  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。

                目前,只有G1 GC会有这种行为

  • 整堆收集(Full GC):收集整个java堆方法区的垃圾收集

4.2 年轻代Gc(Minor GC)触发机制

        当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden满,Survivor满不会引发GC。

        因为Java对象大多都具备朝生夕灭的特性,所以 Minor GC非常频繁,一般回收速度也比较快。Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

4.3 老年代GC (Major GC/Full GC)触发机制

        出现Major GC,经常会伴随至少一次的Minor GC(但非绝对的),也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC。Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。如果Major GC后,内存还不足,就出现OOM。

        触发Full GC执行的情况有如下五种:
(1)调用System.gc()时,系统建议执行Full GC,但是不必然执行

(2)老年代空间不足

(3)方法区空间不足

(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

(5)由Eden区、survivor space0 (From Space)区向survivor space1 (ToSpace)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

        总结起来就一点:老年代或方法区空间不够用了。

五、内存分配机制 

5.1 一般机制 

        如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1 。对象在 Survivor区中每熬过一次MinorGC,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15 岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过选项-XX:MaxTenuringThreshold来设置。

5.2 直接进入老年代的情况

  • 大对象(Eden或Survivor放不下)直接分配到老年代
  • 长期存活(年龄大于MaxTenuringThreshold)的对象分配到老年代
  • 动态对象年龄判断。如果survivor区中相同年龄的所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。

5.3 TLAB(Thread Local Allocation Buffer)

5.3.1 为什么要有TLAB 

        堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。为避免多个线程操作同一地址,需要使用加锁等机制,这又会影响分配速度。 

5.3.2 什么是TLAB

        JVM从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,为每个线程分配了一个私有缓存区域,它包含在Eden空间内,这块区域就叫TLAB。

        多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

5.3.3 TLAB说明 

        尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选

        在程序中,开发人员可以通过选项-XX:UseTLAB设置是否开启TLAB空间。默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent 设置TLAB空间所占用Eden空间的百分比大小。

        一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

5.4 空间分配担保:-XX:HandlePromotionFailure

        在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor Gc是安全的
  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的
      • 如果小于,则改为进行一次Full GC
    • 如果HandlePromotionFailure=false,则改为进行一次Full GC。

        在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了
HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。
 

 六、堆空间常用参数设置总结

  • -XX:+PrintFlagsInitial :查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和s0/s1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
    • 打印GC简要信息:1、-XX:+PrintGC         2、-verbose: gc
  • -XX: HandlePromotionFailure:是否设置空间分配担保

七、逃逸分析 

7.1 堆是分配对象存储的唯一选择吗?

        在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

        在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

        此外,基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap〉技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。 

7.2 什么叫逃逸? 

        如何快速地判断是否发生了逃逸:就看方法内new的对象实体是否有可能在方法外被调用,不可能的话就不会逃逸出方法。

         以下是几种情况:

public class EscapeAnalysis {

    public EscapeAnalysis obj;

    /*
    方法返回EscapeAnalysis对象,发生逃逸
     */
    public EscapeAnalysis getInstance(){
        return obj == null? new EscapeAnalysis() : obj;
    }

    /*
    为成员属性赋值,发生逃逸
     */
    public void setObj(){
        this.obj = new EscapeAnalysis();
    }
    //思考:如果当前的obj引用声明为static的?仍然会发生逃逸。

    /*
    对象的作用域仅在当前方法中有效,没有发生逃逸
     */
    public void useEscapeAnalysis(){
        EscapeAnalysis e = new EscapeAnalysis();
    }

    /*
    引用成员变量的值,发生逃逸
     */
    public void useEscapeAnalysis1(){
        EscapeAnalysis e = getInstance();
        //getInstance().xxx()同样会发生逃逸
    }
}

 7.3 逃逸分析概述

        将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

        通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

        没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。 

        类似的代码中,对象就不会发生逃逸: 

        由此也可知,我们在开发中能使用局部变量的话,就不要使用在方法外被定义的变量。 

7.4 代码优化 

        使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  2. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

7.4.1 栈上分配

        JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

        常见的栈上分配的场景:给成员变量赋值、方法返回值、实例引用传递。

        开启栈上分配(默认开启): -XX:+DoEscapeAnalysis

        例如以下代码,就可以通过栈上分配明显地优化执行效率:

public class StackAllocation {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        // 查看执行时间
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为: " + (end - start) + " ms");
        // 为了方便查看堆内存中对象个数,线程sleep
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    private static void alloc() {
        User user = new User();//未发生逃逸
    }

    static class User {

    }
}

7.4.2 同步省略

        线程同步的代价是相当高的,同步的后果是降低并发性和性能。

        在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

        例如一下代码:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

        代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

7.4.3 标量替换

        标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate) ,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。例如String就是聚合量,因为他可以被分为byte数组。

        在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。 

        那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

        标量替换为栈上分配提供了很好的基础。 

        开启标量替换:-XX:-EliminateAllocations 

        例如下面的代码中,User对象属于聚合量,并且在alloc方法中没有发生逃逸,这就符合标量替换的规则:

public class ScalarReplace {
    public static class User {
        public int id;
        public String name;
    }

    public static void alloc() {
        User u = new User();//未发生逃逸
        u.id = 5;
        u.name = "www.atguigu.com";
    }

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为: " + (end - start) + " ms");
    }
}

7.5 逃逸分析并不成熟

        关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

        其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除,但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。在极端情况下,也就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

        虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段

        注意到有一些观点认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。事实上,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上

        目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面的结论:对象实例都是分配在堆上


http://www.kler.cn/a/17189.html

相关文章:

  • 10款PDF合并工具的使用体验与推荐!!!
  • adb shell常用命令
  • Java学习--网络编程
  • 图论基本术语
  • sql server启用远程连接与修改默认端口
  • 漏洞挖掘 | 某医院小程序支付漏洞+越权
  • 海思芯片(hi3536av100)启动模式选择
  • Linux centos搭建web服务器
  • 利用Google Colab免费使用GPU服务器详细攻略
  • 自动驾驶中地图匹配定位技术总结
  • Web常见漏洞描述及修复建议
  • 基于YOLOv5的目标检测系统详解(附MATLAB GUI版代码)
  • vue+springboot 实现人脸识别方向
  • windows 下Node.js 版本管理工具
  • Java方法引用:提高代码可读性和可维护性
  • C++和Python编程语言各自的优缺点总结,分享一下我对程序员职业规划的看法
  • 【id:59】【20分】D. 旅馆顾客统计(静态成员)
  • 应用,auto,内联函数
  • 计算机基础 -- 硬件篇
  • 2023年web前端开发之JavaScript进阶(一)
  • 开心档之C++ 指针
  • Flex弹性布局
  • Vue电商项目--axios二次封装
  • 2023-05-06 GPT替代
  • 容器适配器---deque和STL ---stack queue priority_queue的模拟实现 C++
  • 【刷题之路Ⅱ】LeetCode 61. 旋转链表