zoukankan      html  css  js  c++  java
  • Java垃圾回收机制

    1.前言

      本文归纳一下对Java内存管理机制的理解,尽可能通俗易懂,知识来自于深入理解Java虚拟机一书。

    2.起源

      计算机简单理解就是根据执行计划,通过参数得到结果。执行计划就是程序了,参数就是实际变量,最终运行得到我们要的结果。磁盘由于其廉价且持久化,用于保存程序和数据,但是受制于执行速度,内存的作用就显现出来了。内存运行快,但是昂贵,易失数据(断电),容量远不及存储介质,但是快就是硬道理,计算无非就是要快出结果。由于容量的限制,导致不可能所有数据和程序代码都被加载到内存,又由于操作系统演化到了分时系统,对于内存的管理也就更显突出了。

      有些语言需要代码本身完成内存的分配和释放,典型的就是C++了。这种由开发人员决定如何利用内存无疑是高效、清楚的,但是对于大型项目开发带来了灾难,内存的分配不当,不释放,协作开发都会导致一系列问题。而且内存问题又是难以发现,测试,对程序编写人员个人能力提出了极高的要求,但显然不能指望所有人都能做到。

      为了解决上诉问题,有人就想出由另一个程序来管理内存,对其进行释放,这个典型的就是JVM了。其让开发人员无需过于关注内存的分配和使用,全部搞定,当然完全不关心也是不可能的。这就是单独写一个程序进行内存分配和回收的起源。

      可以想一下,要写一个管理内存的程序,要考虑哪些问题:

        1.如何管理内存,让内存使用更高效

        2.什么情况下对象可以被回收

        3.什么时间,如何进行回收

      这篇文章将对上述问题进行梳理。

    3.探究

    3.1 内存管理

      管理内存是个技术活,因为涉及到分配、回收和再分配的问题,好的设计才能提升效率。

      有一个基本的概念需要理解,那就是顺序读写肯定比离散读写要快,所以为了效率,需要提升的就是内存的连续性了。首先就是开辟一大块内存,然后一块块的进行分配,初次是没有什么问题的,但是回收后就会出现大量的内存碎片,这个肯定是不利于后续使用的。下面对几种算法进行讨论:

    3.1.1 标记-清除算法

      这个就是上面讲的最简单的算法了。标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。这就会造成内存产生大量的片段,不够连续了,还会妨碍分配大对象。

    3.1.2 复制算法

      这个方法就是解决上诉碎片化的一种方案了,简单暴力。将内存分配成两块等大的区域,清理其中一块时,全部按顺序将存活的对象移动到另一边。这样总能保证内存中的数据是连续的。

      但是这个方法存在两个问题,一个是太耗费内存,另一个就是复制太多。这里先扩展一下虚拟机对内存的一个基本定义,新生代和老年代。这么划分是有原因的,虚拟机中大部分的对象都是临时的,处于朝生夕死的状态(98%),但又有些对象会持久存活,甚至贯穿整个运行周期。对于这些情况,虚拟机定义了这么两个区域,可以针对其特性采取不同的算法策略加速回收。

      复制算法就很适合新生代的朝生夕死的情况,这也意味着其不需要将内存等分。现代JVM将新生代划分成一块Eden空间(伊甸园新生),2块Survivor(幸存者,Eden幸存对象)。默认的大小比例是8:1:1。也就是Eden占用80%的空间,Survivor各占10%。具体操作就是:清理的时候,将当前使用的Eden和Survivor中幸存下来的对象,移动到另一块当前没有使用的Survivor区域中,这样浪费的空间也就是一块survivor,10%而已,比一半节省了大量的空间。

      这里还有一些其他细节:10%的survivor不一定完全够用,这个时候就会将一些内容刷新到老年代了。老年代不够用那就是真的要抛出异常了。这里面还涉及的概念有full gc和一般gc,后续进行说明。

    3.1.3 标记-整理算法

      上面复制算法的两个问题,第一个内存虽然通过特性解决了,只浪费了10%的内存,但是存活对象很多的时候,复制的效率低下问题却没有得到解决。上面所说的另一个定义老年代,这个区域的对象大概率都会存活到下一次gc,所以显然使用复制算法不太划算了。这个时候采取的通常就是标记整理算法。通俗的将就是将存活的对象向内存一端靠拢,最后统一清理掉存活对象后面的内存。

    3.1.4 分代收集算法

      这个并不是新的算法,而是上面所提到的思路:根据对象存活时间不同,尽量减少操作,采取合适的回收算法。所以将内存分为了新生代、老年代,并且新生代常采取复制算法,老年代常采取标记整理算法。当然,这个不是绝对的,要根据实际使用的回收器。

    3.2 什么对象需要回收

      这个问题很好解答:不可能被再次使用的对象,即是无用对象,可以被回收。问题就在于如何判断一个对象不再被使用呢?

    3.2.1 引用计数法

      这种方法很好理解:每个对象有一个计数器,如果有一个地方引用了它,计数器加1,引用失效就减一。对象创建的时候引用当然不会为0,为0后再也找不到这个对象了,自然就可以被回收了。这个方法实现简单,判断效率很高,Redis就是采用了这种算法,有一些语言比如Python也是使用这种方法,但是至少是主流的JVM没有使用这种方法。

      原因在于循环引用:A引用了B,B引用了A,这二者都不为0,但是其他任何地方都与这两个地方没关系。这种情况下也是应该被清理的对象,但是实际上引用计数法无法处理该情况。

    3.2.2 可达性分析

      为了解决上诉的问题,JVM采取了可达性分析的方法。从一系列称为“GC Roots”的对象作为起始点,向下搜索所有可以被访问到的对象,如果有对象不能被搜索到,那么其一定就不可用。AB对象互相引用,但是GC Roots无法搜索到,这样循环引用的无效对象问题就解决了。

      GC Roots对象在我看来就是当前绝对不能对清理的对象,满足这一条件的对象有以下几种:

        1.虚拟机栈(栈帧中的本地变量表)中引用的对象。  立刻就要用了,怎么能被清理

        2.方法区中类静态属性引用对象,常量引用对象。    方法区的static和final基本上和类加载通生命周期了,妥妥的命久对象,不能清理

        3.本地方法栈中JNI(Native方法)引用的对象。    举个不知道对不对的例子,线程开启完不保存就无法被引用了,但是在本地方法栈中还是被线程管理器持有,这个当然不能清理。

    3.2.3 再谈引用

      引用如果只能被标记为被引用和不被引用就比较狭窄,对于一些可有可无的就难以抉择了。比如设计一个缓存服务,有内存的时候当然好,缓存保留。但是内存不够的时候,就会希望能够行之有效的减少这些缓存。因为缓存肯定是要被使用的,肯定回收算法无法对其回收,这个时候想要丢弃都没有办法。JDK1.2之后提出了四种引用:强引用,软引用,弱引用,虚引用。网上有很多对这些引用的使用的讲解,这里推荐篇文章,不做过多描述:这里

      强引用:普遍存在,new之类的。强引用存在,就不会被回收。

      软引用:用于有作用,但是非必须的对象。发生内存溢出之前,会进行回收,如果回收后还不够,会抛出异常。SoftReference类。(用来做缓存)

      弱引用:非必需的对象。比软引用更弱,只能生存到下一次垃圾收集发生之前,当垃圾回收时都会清除掉。WeakReference类来实现。(一次性使用,自动回收,WeakHashMap实现,tomcat中用来做分代缓存:这里

      虚引用:幽灵引用或者是幻影作用,最弱的关系。不会对该对象生存时间构成影响,唯一的作用就是在清除后会收到一个通知。PhantomReference来实现。(用来做对象销毁后的一些操作,finalize也能做到相同的效果,但是由于其相关的一些问题,不好使用:这里

    3.2.4 回收过程

      不可达的对象并不是一定会被回收,主要是因为要进行两次标记。

      如果不可达,首先进行第一次标记,并判断是否需要执行finalize方法。没有覆写finalize方法,或者这个方法被执行过了,就不需要再次执行。这种情况会被直接清理掉。

      如果需要执行finalize方法,就会被放在F-Queue队列中,由Finalizer线程执行,执行的含义是会触发,但不一定会等运行结束:因为执行缓慢或者死循环会导致F-Queue中的其他对象等待,造成回收系统崩溃。finalize方法中,使得这个对象被其他对象引用,就可以逃脱回收的命运。因为之后GC会对F-Queue进行第二次标记,如果被其他对象持有,就会移除被回收的集合,如果没有逃脱,这个对象就会真正被回收。

    3.2.5 回收方法区

      很多人认为方法区(永久代)是不需要进行回收的,虚拟机规范中虽然说了不要求在方法区回收,性价比也比较低,但是也有回收的。

      回收的主要内容有两部分:废弃常量和无用类。JDK7将字符常量移除了永久代PermGen(很容易溢出),JDK8甚至移除了永久代,采用元数据Metaspace:这里

      字符常量很容易判断是否需要回收,但是类就比较麻烦了,需要同时满足以下3个条件:

        1.所有该类的实例被回收了(确保没有对象需要使用类的字节码、方法等信息)

        2.加载类的ClassLoader被回收了(确保不再能通过new创建对象)

        3.该类对象的Class对象没有被引用,无法通过反射访问该类的方法。(补充2的反射情况)

      即便都满足了,也不一定会被回收。hotspot提供了-Xnoclassgc参数进行控制,或者使用-verbose:class和-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载卸载信息。前两个可以在Product版使用,UnLoading参数要在FastDebug版才支持。

    3.3 HotSpot实现

      分析可达性的时候,系统必须保证冻结,即在这段时间内没有新的对象产生等。这就是所说的stop the world,会暂停所有的操作,保持不变。目前也是在极力减少停顿时间。

      HotSpot中使用OopMap的数据结构来达到直接得知哪些地方存放着对象引用的目的。在类加载完时,HotSpot就会将对象内什么偏移量上是什么数据类型计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC扫描的时候就可以直接得到这些信息了。

      在OopMap的帮助下,可以很快完成GC Roots的枚举。但是另外一个问题是导致引用变化的指令很多,如果每条指令都生成OopMap开销就很高了。实际上,也并没有为每条指令都生成OopMap,只有在特定的位置记录了信息,这些位置被称为安全点,只有在到达安全点时才能暂停,开始GC。

      安全点的选定基本上是以程序"是否具有让程序长时间执行的特制"为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,”长时间执行“的最明显的特制就是指令序列复用,如方法调用,循环跳转,异常跳转等,所以具有这些功能的指令才会产生Safepoint。

      另一个问题就是如何让所有线程(不包括JNI调用的线程)都跑到安全点上再停顿下来。这里就有两种方式:

        1.抢先式中断:抢先式中断不需要线程的执行代码主动配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复,让它跑到安全点。该方法不再采用。

        2.主动式中断:GC需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时会主动轮询这个标志,发现为真时,就自己中断挂起。轮询标志的地方和安全点是重合的,另外加上创建对象需要分配内存的地方。test指令是生成的轮询指令,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待。

      安全点看似解决了如何进入GC的问题,但实际上却不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序不执行的时候呢?不执行的时候就是没有分配CPU时间,典型的例子就是Sleep或者Blocked状态,线程无法响应JVM的中断请求,到安全的地方去中断挂起,JVM显然也不可能等待线程被重新分配CPU时间,这种情况就需要采取另一种手段——安全区域safe region来解决了。

      安全区域指的就是在一段代码中,引用关系不会发生变化。在这个区域中任意地方GC都是安全的。线程执行到安全区域的时候,就会标识自己已经到了safe region,那么GC的时候就不会管线程状态了。在线程离开安全区域的时候,要检查系统是否已经完成了根节点枚举,如果完成了,线程继续执行,否则必须等到收到可以离开安全区域的信号才行。

    4. 垃圾收集器

      上面说了那么多,这里看下JDK7的Hotspot虚拟机对于垃圾回收的实现。之前提到过JVM将内存人为的分为了年轻代和老年代,这也是为了针对不同生命周期对象使用不同算法提升效率的策略,所以存在使用多种垃圾回收器的情况,下图是回收器所处的代及其可以组合的回收器。

      可以看到年轻代的有:serial、ParNew、Parallel Scavenge

      老年代的有:CMS、Serial Old、Parallel Old

      G1通用于年轻代和老年代,另外连线就是可以进行组合使用的意思了,但是年轻代只能选一个,对应选择一个可以组合的老年代。G1通用,所以选了它就不能选其他的。另外,这个是早期版本的JVM提供的收集器了,近些年又有了极大的发展,比如JDK11提供的ZGC,号称很强大。JDK9改进了G1收集器,并废弃了几种组合DefNew+CMS、ParNew+SerialOld、递增的CMS。

    4.1 Serial收集器

      这个是最基础的收集器,历史悠久,单线程(不仅仅是只会使用一个线程,而且要暂停其他所有的线程)。这就是经典的stop the world了,十分糟糕。试想一下运行1小时,突然停止5分钟,对程序而言非常不利。下图是serial/serial old处理示意图。

      但是这个收集器在1.7版本仍就是运行在client模式下的默认新生代收集器。优点在于简单高效。

    4.2 ParNew收集器

       这个就是serial进化版本,采取的是多线程的方式,其余的都和serial的一样,比如控制参数:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure。工作示意图如下:

      这个收集器除了多线程之外,其他的与serial并没有太多创新之处,但是它却是许多运行在Server模式下的虚拟机首选的新生代收集器,因为除了Serial收集器外,只有它能够与CMS收集器(这个是第一个真正意义的并发收集器,第一次实现了让垃圾收集线程与用户线程同时工作)配合工作。

      不幸的是,CMS作为老年代收集器,无法与Parallel Scavenge配合工作。所以只能选择ParNew或者Serial中的一个。如果使用参数-XX:+UseConcMarkSweepGC后,默认使用的就是ParNew,也可以使用-XX:+UseParNewGC选项强制指定。

      在单CPU上,ParNew由于线程切换,效果肯定不如Serial,一般线程数与CPU核数相关,可以通过-XX:ParallelGCThreads来限制线程数。

    4.3 Parallel Scavenge收集器

      这是一个新生代收集器,使用的也是复制算法,并行多线程,与ParNew有什么区别呢?特点就是关注目标和其他收集器不同。

      收集器的一般目标是尽快的完成清理动作,停顿时间越短越好。但是Parallel不同,它关注的是吞吐量,即用户代码运行时间占总运行时间的比例。计算公式是:运行用户代码时间/(运行用户代码时间+垃圾收集时间),比如如果总共运行了10分钟,垃圾回收用了1分钟,吞吐量就是90%。

      强交互性的任务就需要停顿时间越短越好,比如一个鼠标点击事件,遇到垃圾回收等了1分钟,那就受不了了。而高吞吐量就不适合交互性的任务了,但是其反应的是更高效的运用CPU,所以运算效率会高。

      -XX:MaxGCPauseMillis设置最大垃圾回收的停顿时间,设置短是会牺牲吞吐量和新生代,会导致收集频繁

      -XX:GCTimeRatio设置吞吐量大小,这个值大于0小于100的整数。比如设置19,就是GC占时是5%. (1/(1+19)),默认值是99,就是1%的GC时间。

      -XX:+UseAdaptiveSizePolicy,设置这个就不需要设置-Xmn -XX:SurvivorRatio、-XX:PretenureSizeThreshold等细节参数了,会自适应。只需要设置Xmx最大堆,和上面两个参数即可。

    4.4 Serial Old收集器

      这是一个老年代收集器,Serial的Old版本。同样的单线程,采用标记-整理算法,给Client模式下使用。

      还有两个用途:

        1.与Parallel Scavenge收集器搭配使用

        2.作为CMS的后备方案,发生Concurrent Mode Failure时使用。

    4.5 Parallel Old收集器

      这个是用于解救之前新生代使用Parallel Scavenge时,老年代只能选择Serial Old这个性能不佳的收集器的困境。

      Parallel Scavenge + Serial Old的吞吐量不一定比ParNew+ CMS组合强,这个收集器弥补了这个尴尬之处,更适合用于注重吞吐量的场合。

    4.6 CMS收集器

       Concurrent Mark Sweep收集器是一种以获取最短回收停顿时间为目标的收集器。目前很多Java程序集中在互联网站或者B/S系统的服务端上,这类服务尤其注重响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

      CMS采取的不像之前的老年代收集器采用的标记整理算法,其使用的是标记-清除算法,整个过程分为4个步骤:

        1.初始标记:暂停所有线程,标记一下GC Roots能直接关联到的对象,速度很快

        2.并发标记:不需要暂停所有线程,是进行GC Roots Tracing的过程

        3.重新标记:暂停线程,修正并发标记期间因用户程序继续运作而导致标记产生变动的一部分对象的标记记录,耗时比初始标记长,并发标记短

        4.并发清除:不需要暂停线程,清除标记的对象

      CMS有3个显著的缺点:

        1.对CPU资源敏感。并发阶段虽然不会导致用户线程停顿,但是会占用一部分资源导致应用程序变慢,总吞吐量降低。默认的回收线程数是(CPU+3)/4,不少于25%的CPU资源。开发了一种增量式并发收集器,CMS的变种,在并发阶段让GC线程和用户线程交替运行,减少GC独占时间,效果不好。之前也提到了JDK9中废弃了。

        2.无法处理浮动垃圾,可能出现Concurrent Mode Failure,而导致另一次Full GC的产生。并发清理阶段用户线程还在产生垃圾,这部分会被留到下一次GC处理,被称为浮动垃圾。所以CMS不能像其他收集器那样等待老年代几乎完全被填满了再进行收集,需要留一部分空间应对这种情况。JDK5中,老年代使用了68%会被触发,可以通过参数-XX:CMSInitiatingOccupancyFraction的值提高触发百分比。如果预留空间不够,就会触发Concurrent Mode Failure,会使用预备方案,之前说的Serial Old收集器进行清理,停顿时间就更长了。

        3.标记-清除会产生大量空间碎片,对大对象分配带来很大的麻烦,会提前触发Full GC。-XX:+UseCMSCompactAtFullCollection开关参数(默认开启)用于在CMS收集器要Full GC时进行内存碎片的合并整理,碎片整理是没办法并发进行的,所以停顿时间更长了。-XX:CMSFullGCsBeforeCompaction,这个参数用于设置进行多少次不压缩的Full GC后进行一次压缩的,默认0表示每次都压缩。

    4.7 G1收集器

      G1收集器在JDK9被设置成默认使用的收集器了,这几年有了更好的发展。这是一款面向服务端应用的垃圾收集器。赋予它的使命就是在替换掉JDK1.5发布的CMS收集器。也可以看出经历的时间很长才完成了这款收集器。

      G1有以下特点:

        1.并发与并行:充分利用多CPU、多核环境,缩短Stop-The-World时间

        2.分代收集:分代概念仍保留在G1中,虽然它一个就管理了新生代和老年代。

        3.空间整合:从整体上是基于标记-整理算法实现的收集器,局部上是基于复制算法实现的。不会产生大量碎片。

        4.可预测的停顿:除了追求停顿外,还建立可预测的停顿时间模型,让使用者指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒,几乎是实时Java(RTSJ)的垃圾回收器的特制了。

      虽然保留了分代概念,但是其是将堆划分成多个大小相等的独立区域,不再是物理隔离了。之所以能够建立停顿时间模型,也是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个区域的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先收回价值最大的区域,保证在有限时间内达到效率最大。思路虽然简单,实现起来非常复杂,因为区域并非孤立,不可能只扫描一个区域,不然其他区域引用了如何判断?从04年G1的理论到现在才被设置成默认的收集器,可见其困难。

      在G1中,使用Remembered Set来避免全堆扫描,每个区域都有一个这个Set,虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable将相关信息记录到被引用对象所属的区域的Remembered Set中。回收时,通过这个就能保证不进行全堆扫描也能知道该区域对象有没有被其他区域引用。

      G1回收步骤与CMS相似,分为以下阶段:

        1.初始标记:标记一下GC Roots能直接关联到的对象,修改TAMS的值,让下一阶段用户程序并发运行时,能在正确可用的区域创建新对象

        2.并发标记:从堆中对象进行可达性分析,找出存活的对象,可并发执行

        3.最终标记:修正并发标记过程中由于用户程序执行导致标记产生变化的记录,变化记录在线程的Remembered Set Logs里面,会将这部分数据合并到Remembered Set中。

        4.筛选回收:对各个区域进行排序,根据用户期望的GC停顿时间制定回收计划。

     

    5 GC日志

      时间:【GC类型【发生区域:GC前使用容量->GC后使用容量(该区域总容量),GC耗时】GC前Java堆使用容量->GC后Java堆使用容量(堆总容量),总GC耗时】

      例如:33.125: [GC  [DefNew: 3324K->152K(3712K), 0.0025925 secs]  3324K->152K(11904K), 0.0031680 secs]

         发生区域根据不同的收集器,不同代命名不同。

         DefNew就是serial的新生代

         ParNew:是ParNew的新生代

         PSYoungGen:是Parallel Scavenge的新生代

      还有另一种格式,例如:

        100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456), [Perm: 2999K -> 2999K(21248K)],0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

      时间上user就是用户态消耗的CPU时间,sys是内核态消耗的CPU时间,real指从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间区别在于:墙钟时间包含各种非运算的等待耗时,例如磁盘IO等,CPU不包含。但是如果是多核CPU,多线程操作会叠加这些时间,所以会看到user或sys时间超过real时间。

       通过参数-XX:+PrintGCDetails这个参数打印内存回收日志。

    6 内存分配过程

      大部分情况下,对象在新生代的Eden区进行分配,没有足够空间的时候,触发一次Minor GC,内存依旧不够,将对象移动到老年代这个担保区域。

      大对象需要大量的连续空间,比如byte[],会导致触发垃圾回收,更糟糕的情况是遇到一群大对象。虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个设置值的对象直接在老年代进行分配。避免在Eden和两个Survivor区之间发生大量的内存复制。这个参数必须写成字节数,不能直接写MB。

      另一种进入老年代的方法就是满足了年龄阈值,每进行一次Minor GC后对象仍或者,其年龄就会加1,到达阈值就会进入老年代。通过-XX:MaxTenuringThreshold设置。

      为了更好的适用内存状况,不一定必须达到年龄才能晋升老年代,如果survivor空间中相同年龄所有对象大小总和大于survivor空间的一半,年龄大于等于这个年龄的对象就可以直接进入老年代。

      在执行Minor GC之前,会检查老年代的最大可用连续空间是否大于新生代所有对象总和,成立那么Minor GC就是安全的。因为老年代是在新生代空间不足时的担保方,最差的情况就是所有对象都进入了老年代,所以只要老年代的空间足够,那么Minor GC一定安全。如果不安全,就会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,就会检查老年代的连续最大可用空间是否大于历次晋升到老年代对象的平均大小,如果大于,会尝试进行Minor GC,尽管此次也有风险,小于或者不允许失败,会改成进行Full GC。JDK6 Update24后,这个参数没有实际作用了,大于平均晋级大小,就会进行Minor GC,否则就是Full GC。

  • 相关阅读:
    图像的卷积
    信息理论与编码中有关信源编码的笔记
    Java 数组排序
    完全平方数
    Java 作业题4
    Java 作业题3
    Java 作业题 2
    算法面试题二:旋转数组,存在重复元素,只出现一次的数字
    算法面试题一:排序算法及贪心算法
    微信小程序 发送模板消息的功能实现
  • 原文地址:https://www.cnblogs.com/lighten/p/9317639.html
Copyright © 2011-2022 走看看