• Java内存泄露*


    一、问题的提出

    Java的一个重要优点就是通过垃圾收集器(Garbage Collection,GC)自动管理内存的回收,程序员不需要通过调用函数来释放内存。因此,很多程序员认为Java不存在内存泄漏问题,或者认为即使有内存泄漏也不是程序的责任,而是GC或JVM的问题。其实,这种想法是不正确的,因为Java也存在内存泄露,但它的表现与C++不同。

    随着越来越多的服务器程序采用Java技术,例如JSP,Servlet, EJB等,服务器程序往往长期运行。另外,在很多嵌入式系统中,内存的总量非常有限。内存泄露问题也就变得十分关键,即使每次运行少量泄漏,长期运行之后,系统也是面临崩溃的危险。

    二、Java是如何管理内存

    为了判断Java中是否有内存泄露,我们首先必须了解Java是如何管理内存的。Java的内存管理就是对象的分配和释放问题。分配内存的方式多种多样,取决于该种语言的语法结构。但不论是哪一种语言的内存分配方式,最后都要返回所分配的内存块的起始地址,即返回一个指针到内存块的首地址。在Java中,程序员需要通过关键字new为每个对象申请内存空间 (基本类型除外),所有的对象都在堆(Heap)中分配空间。另外,对象的释放是由GC决定和执行的。在Java中,内存的分配是由程序完成的,而内存的释放是有GC完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是Java程序运行速度较慢的原因之一。

    因为,GC为了能够正确释放对象,GC必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC都需要进行监控。

    监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。

    为了更好理解GC的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用者指向被引对象。另外,每个线程对象可以作为一个图的起始顶点,例如大多程序从main进程开始执行,那么该图就是以main进程顶点开始的一棵根树。在这个有向图中,根顶点可达的对象都是有效对象,GC将不回收这些对象。如果某个对象 (连通子图)与这个根顶点不可达(注意,该图为有向图),那么我们认为这个(这些)对象不再被引用,可以被GC回收。

    以下,我们举一个例子说明如何用有向图表示内存管理。对于程序的每一个时刻,我们都有一个有向图表示JVM的内存分配情况。以下右图,就是左边程序运行到第6行的示意图。

      

    Java使用有向图的方式进行内存管理,可以消除引用循环的问题,例如有三个对象,相互引用,只要它们和根进程不可达的,那么GC也是可以回收它们的。这种方式的优点是管理内存的精度很高,但是效率较低。另外一种常用的内存管理技术是使用计数器,例如COM模型采用计数器方式管理构件,它与有向图相比,精度行低(很难处理循环引用的问题),但执行效率很高。

     Java 中,所有对象都驻留在堆内存,因此局部对象就不存在。当你创建一个对象时,Java 虚拟机(JVM)为该对象分配内存、调用构造器并开始跟踪你使用的对象。当你停止使用一个对象(就是说,当没有对该对象有效的引用时),JVM 通过垃圾回收器将该对象标记为释放状态。当垃圾回收器要释放一个对象的内存时,它首先调用该对象的finalize() 方法(如果该对象定义了此方法的话)。垃圾回收器以独立的低优先级的方式运行,所以只有当其他线程都挂起等待内存释放的情况出现时,它才开始释放对象的内存。

    三、java垃圾收集器

    垃圾收集器线程是一种低优先级的线程,在一个Java程序的生命周期中,它只有在内存空闲的时候才有机会运行。

    垃圾收集器的特点和它的执行机制:

    垃圾收集器系统有自己的一套方案来判断哪个内存块是应该被回收的,哪个是不符合要求暂不回收的。垃圾收集器在一个Java程序中的执行是自动的,不能强制执行,即使程序员能明确地判断出有一块内存已经无用了,是应该回收的,程序员也不能强制垃圾收集器回收该内存块。程序员唯一能做的就是通过调用System. gc 方法来"建议"执行垃圾收集器,但其是否可以执行,什么时候执行却都是不可知的。这也是垃圾收集器的最主要的缺点。当然相对于它给程序员带来的巨大方便性而言,这个缺点是瑕不掩瑜的。

    垃圾收集器的工作是发现应用程序不再需要的对象,并在这些对象不再被访问或引用时将它们删除。从根节点(在java应用程序的整个生存周期内始终存在的那些类)开始,遍历被应用的所有节点进行清除。在它遍历这些节点的同时,它跟踪哪些对象当前正被引用着。任何类只要不再被引用,它就符合垃圾收集的条件。当删除这些对象后,就将它们所占用的内存资源返回给jvm。

    1)、垃圾收集器的主要特点:

    1.垃圾收集器的工作目标是回收已经无用的对象的内存空间,从而避免内存渗漏体的产生,节省内存资源,避免程序代码的崩溃。

    2.垃圾收集器判断一个对象的内存空间是否无用的标准是:如果该对象不能再被程序中任何一个"活动的部分"所引用,此时我们就说,该对象的内存空间已经无用。所谓"活动的部分",是指程序中某部分参与程序的调用,正在执行过程中,尚未执行完毕。

    3.垃圾收集器线程虽然是作为低优先级的线程运行,但在系统可用内存量过低的时候,它可能会突发地执行来挽救内存资源。当然其执行与否也是不可预知的。

    4.垃圾收集器不可以被强制执行,但程序员可以通过调用System. gc方法来建议执行垃圾收集器。

    5.不能保证一个无用的对象一定会被垃圾收集器收集,也不能保证垃圾收集器在一段Java语言代码中一定会执行。因此在程序执行过程中被分配出去的内存空间可能会一直保留到该程序执行完毕,除非该空间被重新分配或被其他方法回收。由此可见,完全彻底地根绝内存渗漏体的产生也是不可能的。但是请不要忘记,Java的垃圾收集器毕竟使程序员从手工回收内存空间的繁重工作中解脱了出来。设想一个程序员要用C或C++来编写一段10万行语句的代码,那么他一定会充分体会到Java的垃圾收集器的优点!

    6.同样没有办法预知在一组均符合垃圾收集器收集标准的对象中,哪一个会被首先收集。

    7.循环引用对象不会影响其被垃圾收集器收集。

    8.可以通过将对象的引用变量(reference variables,即句柄handles)初始化为null值,来暗示垃圾收集器来收集该对象。但此时,如果该对象连接有事件监听器(典型的 AWT组件),那它还是不可以被收集。所以在设一个引用变量为null值之前,应注意该引用变量指向的对象是否被监听,若有,要首先除去监听器,然后才可以赋空值。

    9.每一个对象都有一个finalize( )方法,这个方法是从Object类继承来的。

    10.finalize( )方法用来回收内存以外的系统资源,就像是文件处理器和网络连接器。该方法的调用顺序和用来调用该方法的对象的创建顺序是无关的。换句话说,书写程序时该方法的顺序和方法的实际调用顺序是不相干的。请注意这只是finalize( )方法的特点。

    11.每个对象只能调用finalize( )方法一次。如果在finalize( )方法执行时产生异常(exception),则该对象仍可以被垃圾收集器收集。

    12.垃圾收集器跟踪每一个对象,收集那些不可到达的对象(即该对象没有被程序的任何"活的部分"所调用),回收其占有的内存空间。但在进行垃圾收集的时候,垃圾收集器会调用finalize( )方法,通过让其他对象知道它的存在,而使不可到达的对象再次"复苏"为可到达的对象。既然每个对象只能调用一次finalize( )方法,所以每个对象也只可能"复苏"一次。

    13.finalize( )方法可以明确地被调用,但它却不能进行垃圾收集。

    14.finalize( )方法可以被重载(overload),但只有具备初始的finalize( )方法特点的方法才可以被垃圾收集器调用。

    15.子类的finalize( )方法可以明确地调用父类的finalize( )方法,作为该子类对象的最后一次适当的操作。但Java编译器却不认为这是一次覆盖操作(overriding),所以也不会对其调用进行检查。

    16.当finalize( )方法尚未被调用时,System. runFinalization( )方法可以用来调用finalize( )方法,并实现相同的效果,对无用对象进行垃圾收集。

    此时容易引起一个误解——大家可能想finalize() 方法是安全的,这时一些重要的事情需要注意:

    首先,只有当垃圾回收器释放该对象的内存时,才会执行finalize()。如果在 Applet 或应用程序退出之前垃圾回收器没有释放内存,垃圾回收器将不会调用finalize()。

    其次,除非垃圾回收器认为Applet或应用程序需要额外的内存,否则它不会试图释放不再使用的对象的内存。换句话说,有可能出现这样的情况:一个 Applet 给少量的对象分配了内存,但没有造成严重的内存需求,于是垃圾回收器没有释放这些对象的内存程序就退出了。

    显然,如果为某个对象定义了finalize() 方法,JVM可能不会调用它,因为垃圾回收器不曾释放过那些对象的内存。即使调用System.gc() 也可能不会起作用,因为它仅仅是给 JVM 的一个建议而不是命令,所以finalize()方法的作用也就不是那么明显。Java 1.1中有一个System.runFinalizersOnExit()方法部分地解决了这个问题。(不要将这个方法与 Java1.0中的System.runFinalizations()方法相混淆。)不象System.gc() 方法那样,System.runFinalizersOnExit()方法并不立即试图启动垃圾回收器。而是当应用程序或 Applet 退出时,它调用每个对象的finalize()方法。

    结论:不应当依靠垃圾回收器或finalize() 来执行你的 Applet 和应用程序的资源清除工作。取而代之,应当使用确定的方法来清除那些资源或创建一个try...finally 块(或类似的机制)来实现。

    17.当一个方法执行完毕,其中的局部变量就会超出使用范围,此时可以被当作垃圾收集,但以后每当该方法再次被调用时,其中的局部变量便会被重新创建。

    18.Java语言使用了一种"标记交换区的垃圾收集算法"。该算法会遍历程序中每一个对象的句柄,为被引用的对象做标记,然后回收尚未做标记的对象。所谓遍历可以简单地理解为"检查每一个"。

    19.Java语言允许程序员为任何方法添加finalize( )方法,该方法会在垃圾收集器交换回收对象之前被调用。但不要过分依赖该方法对系统资源进行回收和再利用,因为该方法调用后的执行结果是不可预知的。

    通过以上对垃圾收集器特点的了解,你应该可以明确垃圾收集器的作用,和垃圾收集器判断一块内存空间是否无用的标准。简单地说,当你为一个对象赋值为null并且重新定向了该对象的引用者,此时该对象就符合垃圾收集器的收集标准。

    典型地,GC不会自动执行,直到程序需要的内存比当前可用内存多时才调用,此时,jvm将首先尝试激活GC以得到更多的可用内存,如果仍得不到充足的可用内存,jvm将转向从操作系统申请更多的内存,直到最终超过分配的最大内存而导致java.lang.OutOfMemoryError

    四、什么是Java中的内存泄露

    1)、java内存泄漏

    Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

    C++中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来。在Java中,这些不可达的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。

    通过分析,我们得知,对于C++,程序员需要自己管理边和顶点,而对于Java程序员只需要管理边就可以了(不需要管理顶点的释放)。通过这种方式,Java提高了编程的效率。

     

     

    因此,通过以上分析,我们知道在Java中也有内存泄漏,但范围比C++要小一些。因为Java从语言上保证,任何对象都是可达的,所有的不可达对象都由GC管理。

    对于程序员来说,GC基本是透明的,不可见的。虽然,我们只有几个函数可以访问GC,例如运行GC的函数System.gc(),但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低。JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。

    下面给出了一个简单的内存泄露的例子。在这个例子中,我们循环申请Object对象,并将所申请的对象放入一个Vector中,如果我们仅仅释放引用本身,那么Vector仍然引用该对象,所以这个对象对GC来说是不可回收的。因此,如果对象加入到Vector后,还必须从Vector中删除,最简单的方法就是将Vector对象设置为null。

    Vector v=new Vector(10);

    for (int I=1;I<100; I++)

    {

    Object o=new Object();

    v.add(o);

    o=null;

    }

    //此时,所有的Object对象都没有被释放,因为变量v引用这些对象。

    实际上无用,而还被引用的对象,gc 就无能为力了(事实上gc认为它还有用),这一点是导致内存泄露最重要的原因。

    2)、判断一个对象是否符合垃圾收集器的收集标准

    Object obj = new Object ( ) ;

    我们知道,obj为Object的一个句柄。当出现new关键字时,就给新建的对象分配内存空间,而obj的值就是新分配的内存空间的首地址,即该对象的值(对象的值和对象的内容是不同含义的两个概念:对象的值就是指其内存块的首地址,即对象的句柄;而对象的内容则是其具体的内存块)。此时如果有 obj = null; 则obj指向的内存块此时就无用了,因为下面再没有调用该变量了。

    请看以下三种内存管理:

     

    程序段1:

    1.fobj = new Object ( ) ;

    2.fobj. Method ( ) ;

    3.fobj = new Object ( ) ;

    4.fobj. Method ( ) ;

    问:这段代码中,第几行的fobj 符合垃圾收集器的收集标准?

    答:第3行。因为第3行的fobj被赋了新值,产生了一个新的对象,即换了一块新的内存空间,也相当于为第1行中的fobj赋了null值。

    程序段2:

    1.Object sobj = new Object ( ) ;

    2.Object sobj = null ;

    3.Object sobj = new Object ( ) ;

    4.sobj = new Object ( ) ;

    问:这段代码中,第几行的内存空间符合垃圾收集器的收集标准?

    答:第1行和第3行。因为第2行为sobj赋值为null,所以在此第1行的sobj符合垃圾收集器的收集标准。而第4行相当于为sobj赋值为null,所以在此第3行的sobj也符合垃圾收集器的收集标准。

    如果有一个对象的句柄a,且你把a作为某个构造器的参数,即 new Constructor ( a )的时候,即使你给a赋值为null,a也不符合垃圾收集器的收集标准。直到由上面构造器构造的新对象被赋空值时,a才可以被垃圾收集器收集。

    程序段3:

    1.Object aobj = new Object ( ) ;

    2.Object bobj = new Object ( ) ;

    3.Object cobj = new Object ( ) ;

    4.aobj = bobj;

    5.aobj = cobj;

    6.cobj = null;

    7.aobj = null;

    问:这段代码中,第几行的内存空间符合垃圾收集器的收集标准?

    答:自己做,答案明天放在论坛中。

    答:第7行。注意这类题型是认证考试中可能遇到的最难题型了。

    1-3分别创建了Object类的三个对象:aobj,bobj,cobj

    4:此时对象aobj的句柄指向bobj,所以该行的执行不能使aobj符合垃圾收集器的收集标准。

    5:此时对象aobj的句柄指向cobj,所以该行的执行不能使aobj符合垃圾收集器的收集标准。

    6:此时仍没有任何一个对象符合垃圾收集器的收集标准。

    7:对象cobj符合了垃圾收集器的收集标准,因为cobj的句柄指向单一的地址空间。在第6行的时候,cobj已经被赋值为null,但由cobj同时还指向了aobj(第5行),所以此时cobj并不符合垃圾收集器的收集标准。而在第7行,aobj所指向的地址空间也被赋予了空值null,这就说明了,由cobj所指向的地址空间已经被完全地赋予了空值。所以此时cobj最终符合了垃圾收集器的收集标准。 但对于aobj和bobj,仍然无法判断其是否符合收集标准。

     

    总之,在Java语言中,判断一块内存空间是否符合垃圾收集器收集标准的标准只有两个:

    1.给对象赋予了空值null,以下再没有调用过。

    2.给对象赋予了新值,既重新分配了内存空间。

    最后,一块内存空间符合了垃圾收集器的收集标准,并不意味着这块内存空间就一定会被垃圾收集器收集

    五、java内存管理示例

    1)、多重引用

    程序中的对象是否有用和java gc认为对象是否有用是有差别的:

    1、       程序员编写代码通常是认为被创建的对象在其生命周期结束后无用

    2、       而gc认为只有对象的引用记数=0的时候,该对象才是无用的。

    两者会产生不一致的地方,如下图所示:

     

     

    代码(程序员的观点)                          gc(java)

    Public void fun1(){

    ……

    //创建局部变量E

    Object E = new E();                             E.count ++

    A.a = E;                                      E.count ++

    B.b = E;                                      E.count ++

    C.c = E;                                      E.count ++

    D.d = E;                                      E.count ++

     

    //我们认为

    //E没用了,释放E

    E = null;                                      E.count –

    ……

    }

    认为已无用                                    E的引用数=4

                                                                       仍旧有用

    应该释放                                                   gc不负责释放

     

    结论:

    1、       如果要释放对象,就必须使其的引用记数为0,只有那些不再被引用的对象才能被释放,这个原理很简单,但是很重要,是导致内存泄露的基本原因,也是解决内存泄露方法的宗旨。

    2、       程序员无须管理对象空间具体的分配和释放过程,但必须要关注被释放对象的引用记数是否为0

    3、       一个对象可能被其他对象引用的过程的几种

    a、直接赋值,如上例中的A.a = E;

    b、通过参数传递,例如public void addObject(Object E)

    c、其它一些情况如系统调用等。

    2)、几种容易遗忘并导致不能释放的引用情况

    1、通常的无用引

     

    上面说明了在java应用程序执行期间具有不同生存周期的两个类,类A首先被实例化,并会在很长一段时间或程序的整个生存周期内存在,在某个时候,类B被创建,类A添加对这个新创建的类的一个引用。现在,我们假定类B是某个用户界面小部件,它由用户显示甚至解除。如果没清除类A对B的引用,则即便不再需要类B,并且在执行下一个垃圾收集周期以后,类B仍将存在并占用内存空间

    2、内部类的引用

    内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放,从内部类的引用来看我们要释放对象A,需要做到的不仅是将对象A的引用记数清为0,最好是将指向A对象以及A对象内部成员的引用都清为0

     

    3、监听器引用

    java编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如AddXXXListener()等方法来增加监听器,建议在释放对象的时候删除这些对象,如果不这样做,那么程序存在内存泄露的机会将增大很多。

    4、外部模块的引用

    对于程序员而言,自己的程序很清楚,如果发现内存泄露,自己对这些对象的引用可以很快定位并解决,但是现在的应用软件并非一个人实现,模块化的思想在现代软件中非常明显,所以程序员要小心外部模块不经意的引用,例如程序员A负责A模块,它调用了B模块的一个方法如:

    public void registerMsg(Object b);

    这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B是否提供相应的操作去除引用。

    5、系统的引用

    这种引用比较少,但很难定位和解决,类似监听器引用,当创建系统类对象,譬如线程、定时器、面板、颜色选择框、文件选择对话框等,可能会产生一些引用,导致和它们相关的类不能释放,对于其中的原因和解决方案,大家可以去研究。

    3)、顺序和效率

    当发现有很多类对象没有被释放的时候,不要急于解决这些问题,要分析这些类对象的关系,举例如下:

    如果只要释放b圈内的对象,那么最好从A1开始释放,首先关注a1的释放情况,如果要释放c圈内的对象,那么最好从A对象释放开始做起,这和java GC的工作机制一样,就可以提高我们解决内存泄露问题的效率。

    代码修改的原则:

    如果要释放一个对象,就要将指向该对象的引用清空,最好连带指向该对象内部的引用也清空。例如,要释放A4对象,就要释放A1->A4的应用A2->A4的引用

     

    4)、经验总结、预防措施和规范建议

    1、容器的removeall()方法

    当类从Jpanel或Jdialog或其它容器类继承的时候,删除该对象之前不妨调用它的removeall()方法

    2、线程的interrupe()方法

    当类对象是一个Thread的时候,删除该对象之前不妨调用它的interrupe()方法

    3、JfileChooser的removeChoosableFileFilter()

    如果创建了一个JfileChooser,并且加入了自己的文件过滤器,删除该对象之前不妨调用它的removeChoosableFileFilter()方法

    4、调用Timer和TimerTask的Cancel()方法

    5、当不需要一个类时,最好删除它的监听器

    6、内存检测过程中不仅要关注自己编写的类对象,同时也要关注一些基本类型的对象,例如:int[],String,char[]等等。

    7、在确认一个对象无用后,将其所有引用显式的置为null

    六、如何检测内存泄漏

    最后一个重要的问题,就是如何检测Java的内存泄漏。目前,我们通常使用一些工具来检查Java程序的内存泄漏问题。市场上已有几种专业检查Java内存泄漏的工具,它们的基本工作原理大同小异,都是通过监测Java程序运行时,所有对象的申请、释放等动作,将内存管理的所有信息进行统计、分析、可视化。开发人员将根据这些信息判断程序是否有内存泄漏问题。这些工具包括Optimizeit Profiler,JProbe Profiler,JinSight , Rational 公司的Purify等。

    下面,我们将简单介绍Optimizeit的基本功能和工作原理。

    Optimizeit Profiler版本4.11支持Application,Applet,Servlet和Romote Application四类应用,并且可以支持大多数类型的JVM,包括SUN JDK系列,IBM的JDK系列,和Jbuilder的JVM等。并且,该软件是由Java编写,因此它支持多种操作系统。Optimizeit系列还包括Thread Debugger和Code Coverage两个工具,分别用于监测运行时的线程状态和代码覆盖面。

    当设置好所有的参数了,我们就可以在OptimizeIt环境下运行被测程序,在程序运行过程中,Optimizeit可以监视内存的使用曲线(如下图),包括JVM申请的堆(heap)的大小,和实际使用的内存大小。另外,在运行过程中,我们可以随时暂停程序的运行,甚至强行调用GC,让GC进行内存回收。通过内存使用曲线,我们可以整体了解程序使用内存的情况。这种监测对于长期运行的应用程序非常有必要,也很容易发现内存泄露。

     

    在运行过程中,我们还可以从不同视角观查内存的使用情况,Optimizeit提供了四种方式:

        堆视角。 这是一个全面的视角,我们可以了解堆中的所有的对象信息(数量和种类),并进行统计、排序,过滤。了解相关对象的变化情况。

        方法视角。通过方法视角,我们可以得知每一种类的对象,都分配在哪些方法中,以及它们的数量。

        对象视角。给定一个对象,通过对象视角,我们可以显示它的所有出引用和入引用对象,我们可以了解这个对象的所有引用关系。

        引用图。 给定一个根,通过引用图,我们可以显示从该顶点出发的所有出引用。

    在运行过程中,我们可以随时观察内存的使用情况,通过这种方式,我们可以很快找到那些长期不被释放,并且不再使用的对象。我们通过检查这些对象的生存周期,确认其是否为内存泄露。在实践当中,寻找内存泄露是一件非常麻烦的事情,它需要程序员对整个程序的代码比较清楚,并且需要丰富的调试经验,但是这个过程对于很多关键的Java程序都是十分重要的。

    综上所述,Java也存在内存泄露问题,其原因主要是一些对象虽然不再被使用,但它们仍然被引用。为了解决这些问题,我们可以通过软件工具来检查内存泄露,检查的主要原理就是暴露出所有堆中的对象,让程序员寻找那些无用但仍被引用的对象。

  • 相关阅读:
    别的程序员是怎么读你的简历的
    .NET平台的ORM分析工具
    有关各个版本的Visual Studio(VS)和SQL Server安装的顺序总结
    QQ空间魔力日志大全SduSRZ
    【Python】哈姆雷特字数统计
    【Python】统计
    【Python】koch雪花
    【Python】汉诺塔问题
    【Python】七个数码管年月日
    【Python】圆周率计算
  • 原文地址:https://www.cnblogs.com/chenxibobo/p/6135003.html
Copyright © 2020-2023  润新知