`
myangle89
  • 浏览: 95917 次
  • 性别: Icon_minigender_1
  • 来自: 上海
社区版块
存档分类
最新评论

JVM内存模型与性能调优

    博客分类:
  • java
jvm 
阅读更多

Java是一门面向对象的编程语言,用对象来定义,描述和操作一切。对象数据存储在计算机内存中,Java的内存模型到底是个什么样子,让Java引为自豪的垃圾回收器又是如何工作的,如何针对JVM的内存管理进行性能调优,笔者将通过本文带您揭开这些Java世界深处不为人知的内幕。
 
(本文系作者原创,请尊重作者的权利。本文欢迎转载,如转载必须注明作者及出处!)
 
文章导航
 
 
堆内存(Heap) 
垃圾回收器(GC) 
垃圾回收策略 
堆栈内存(Stack) 
JVM如何使用堆内存 
堆内存结构图 
内存溢出 
内存优化最佳实践 
内存参数设计示例 
   
堆内存(Heap) 
堆是由Java虚拟机(JVM,下文提到的JVM特指Sun hotspot JVM)用来存放Java类、对象和静态成员的内存空间,Java程序中创建的所有对象都在堆中分配空间,堆只用来存储对象,应用程序通过存放在堆栈(Stack)内的引用来访问堆数据,一个JVM进程只能拥有一个堆。JVM通过-Xms和-Xmx参数分别设置堆的初始值和最大值,初始值默认是物理内存的1/64但小于1G,最大值默认是物理内存的1/4但小于1G 。默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制,可以由-XX:MinHeapFreeRatio来指定百分比。默认空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制,可以由-XX:MaxHeapFreeRatio来设定百分比。
 
Java堆使用分代的方式来管理不同类型的Java对象,通常情况下,JVM会将堆分为三个区域(代):年青代(Young),年老代(Old)和永久代(Permanent),针对不同不区域中的不同类型的Java对象JVM采用不同的垃圾回收算法。年青代用来存放新近创建的对象,尺寸随堆大小的增大和减小而相应的变化,默认值是保持为堆大小的1/15,可以通过-Xmn参数设置年青代为固定大小,也可以通过-XX:NewRatio来设置年青代与年老代的大小比例,年青代的特点是对象更新速度快,在短时间内产生大量的“死亡对象”。年老代用来存放存活时间长相对稳定的对象。在年青代中“存活”次数最多的对象会被移动到年老代,通过-XX:MaxTenuringThreshold参数来设置“存活”几次的对象才被移入年老代,年老代的大小由整个堆空间的尺寸减掉年青代和永久代计算得到 。
 
永久代用来存放类及类的静态成员,这些对象通常来讲很少会被垃圾收集。永久代的初始值和最大值分别通过-XX:PermSize和-XX:MaxPermSize参数来设定。永久代的大小是由JVM独立管理的,并不会随堆的大小变化而变化。当永久代溢出时,JVM会增大永久代的尺寸,但不会超过XX:MaxPermSize设置的最大值,当永久代达到最大值后,继续申请永久代空间将造成系统崩溃(Out of Memery)。年青代在堆内存中是从上向下分配空间的,而永久代则是从下向上分配的,这样作可以最大程度的减少内存碎片的产生。当且只当年老代空间不足时才会触发堆的增长,但不会超过-Xmx设置的上限值。
   
垃圾回收器(GC)
 
Java垃圾回收器是一个或多个运行在JVM中低优先级的守护线程,它负责监视JVM堆空间的使用情况,在预定的条件满足时负责回收“死亡对象”,释放可用的内存空间供应用程序使用。死亡对象是指在JVM所有线程堆栈引用中没有任何一个有效指向的对象,死亡对象是无法重新被程序使用的Java对象,在Java中,通过程序无法释放死亡对象的内存空间,JVM使用垃圾回收器来自动的不间断的回收死亡对象。
 
Java垃圾回收器的三种常用回收算法:
 
 
记数器清除(tracing):该算法使用引用计数器来区分存活对象和死亡的对象。堆中的每个对象对应一个引用计数器。当每一次创建一  个对象并赋给一个变量时,引用计数器置为1。当对象被赋给任意变量时,引用计数器每次加1,当对象出了作用域后(该对象丢弃不再使用),引用计数器减1,一旦引用计数器为0,对象就满足了垃圾收集的条件。  
复制清除(replicate):<该算法将堆内存分成两个相同空间,从根(ThreadLocal的对象,静态对象)开始访问每一个关联的活跃对  象,将空间A的活跃对象全部复制到空间B,然后一次性回收整个空间A。 
标记清除(mark-sweep):收集器先从根开始访问所有活跃对象,标记为活跃对象。然后再遍历一次整个内存区域,把所有没有标记活跃  的对象进行回收处理。 
 
Java垃圾回收器的三种类型:
 
 
串行收集器(Serial):使用单线程处理所有垃圾回收工作,因为无需多线程交互,所以效率比较高。但是,也无法使用多处理器的优势,适合单处理器或小尺寸堆(100M左右)环境下使用。使用-XX:+UseSerialGC参数打开该收集器。  
并行收集器(Parallel):Java5.0新增加的收集器,使用多线程并行的对指定的内存块进行垃圾回收,可以充分的发挥多处理器的优势。使用-XX:+UseParallelGC参数打开该收集器,使用-XX:+UseParallelOldGC参数强制在年老代使用该收集器,使用-XX:ParallelGCThreads参数设置并行收集器使用的线程数 。 
并发收集器 (ConcMarkSweep):该收集器主要是针对标记清除算法,可以保证大部分工作都并发进行(应用不停止),垃圾回收只暂停很少的时间,此收集器适合对响应时间要求比较高的中、大规模应用。使用-XX:+UseConcMarkSweepGC打开。Java5.0默认情况下对年青代使用并行收集器(Java1.4使用的是串行收集器),对年老代使用并发收集器,当然可以使用上面提到的参数强制JVM使用其它的收集器。 
   
垃圾回收策略
 
垃圾回收策略主要是针对年青代和年老代的不同特点分别进行处理的。年青代的特点是产生大量的死亡对象且要求连续的可用空间,所以使用复制清除算法和并行收集器来进行垃圾回收。对年青代的垃圾回收称作叫作初级回收(minor GC)。只有在年青代出现溢出时才会触发初级回收,由于此时年青代已经没有可用空间,所以GC必须挂起应用程序只至回收过程完成。初级回收将年青代分成三个区域,一个新生代(Eden )和两个大小一样的复活代(Survivor),应用程序只能使用新生代和一个复活代(活动复活代),当初级垃圾回收发生时,GC挂起应用程序,将新生代和活动复活代中的存活对象复制到另一个非活动复活代中,然后一次性清除新生代和活动复活代,同时将原来的非复活代标记为活动复活代。初级回收会将存活的对象移动到活动复活代中,将在指定次数回收后仍旧存活的对象移动到年老代中,初级回收的效果是得到一个空的可用的新生代。
 
年老代的特点是尺寸较大且只存在少量的死亡对象,所以使用标记清除算法和并发收集器来进行垃圾回收。对年老代的垃圾回收又称作次级回收(major GC)。由于年老代比年青代大的多,所以相对于初级回收,次级回收将消耗更多的系统资源和时间。次级回收发生时,GC短暂的挂起应用程序以便标记根对象,然后GC会和应用程序并发执行标记所有的非存活对象,当标记完成时GC再次短暂的挂起应用程序完成清除操作。在清除完成后还会执行一次压缩年老代的操作,以便消除在标记清除过程中产生的内存碎片。由于次级回收大部分时间是和应用程序并发进行的,为了在收集过程中给应用程序留出充足可用内存空间,当年老代的可用空间低于68% 时,JVM就会就会触发次级回收,同时也由于并发执行的原因,次级回收不能100%的回收年老代中所有的死亡对象。
 
初级回收和次级回收是完全垃圾回收(Full GC)的一部分,完全垃圾回收还会尝试回收永久代(尽管只有很少的概率能从永久代中回收对象,但JVM还是会这样作),通常情况下完全垃圾回收会在以下情况下触发:
 
 
次级回收后年老代仍然出现溢出 
永久代溢出 
System.gc()被显示调用 
上一次GC之后Heap的各代分配策略动态变化 
 
完全垃圾回收会消耗相当可观的系统资源,并造成应用程序挂起。一次完全垃圾回收后如果整个堆的可用空间仍然小于指定比例将会造成堆空间的增长,如果堆空间已经达到最大值且仍出现代溢出,则会造成系统崩溃(Out of Memery)。
   
堆栈内存(Stack)
 
堆栈(以下简称栈)是由线程管理的一块线性访问的独立于堆的内存空间,一般用来记录程序的执行和存储线程的局部变量,存储在栈中的数据必须在编译阶段确定所占内存的大小。栈空间遵循后进先出的线性访问原则,访问速度要快于堆(Heap)。Java中支持的栈操作只有压栈(Pack)和出栈(Peek)两种,压栈操作将数据存入栈顶,出栈操作将栈顶的数据弹出栈。每一个Java线程对应唯一的一个栈空间,栈空间大小默认值为1M,可以通过设置-Xss参数来增大或缩小为每个线程分配的栈空间,设置过小的栈空间会导致堆栈溢出,设置过大的栈空间会造成最大的线程数减少。
 
存在于堆空间内的对象一般会在某个或某几个线程对应的栈内找到一个或多个引用,当所有线程对应的栈空间内都找不到引用时,堆内存中的对象就是一个“死亡对象”,死亡对象是Java垃圾回收器负责回收的对象。当同一个堆对象在多个线程中被引用时,通过这些线程访问堆对象可能会出现抢占。为了防止抢占现象的发生,Java提供了线程锁定(Lock)方式来为堆对象加锁,被加锁的堆对象只能被持有该锁的唯一一个线程访问(详见《Java多线程》),栈内存放的数据无法进行锁定。
 
线程使用的局部变量(方法内变量),无论该变量是堆对象还是基本数据类型,都不会存在抢占现象,当程序运行于多线程模式下时尽量使用局部变量来避免发生抢占。当局部变量引用一个堆对象时,线程结束后该堆对象就变成一个非存活对象。栈空间不受Java垃圾回收器管理,栈的使用完全由程序代码控制,在程序的编译阶段,线程使用的栈空间已经分配完成。当线程结束后,线程对应的栈空间同时被释放。
 
通过设置-verbose:gc -XX:+PrintGCDetails参数来跟踪系统GC的详细情况,通过设置-XX:+ PrintGCApplicationStoppedTime参数可以显示每次垃圾回收系统挂起的时间。一个好的应用系统会根据年老对象和新生对象的多少来决定堆内存的分配和GC策略的选择,当一个系统存在大量的年老对象时,设置一个小的年青代来减少次级回收的发生频率。反之,当一个系统存在大量的新生对象的时,设置一个大的年青代来减少初级回收的发生频率,有时还需要根据系统加载类的多少来灵活的设置永久代的大小。
   
JVM如何使用堆内存
 
当应用程序生成一个新的Java对象,JVM负责在堆内存中为其申请存储空间,通常内存申请过程如下:
 
 
JVM试图为Java对象在新生代中初始化一块内存区域,当新生代空间足够时,内存申请结束。否则到下一步; 
JVM试图释放新生代中所有死亡的对象,这将引发初级垃圾回收,初级垃圾回收将新生代中的活跃对象移动到复活代,当复活代空间不足时,复活代的对象会被移动到年老代; 
当年老代空间不足时,JVM会在年老代进行次级垃圾收集,如果回收后年老代仍然不足以存放新创建的Java对象,则会引发完全垃圾回收,完全垃圾回收会试图回收永久代; 
如完全垃圾回收后年老代仍然空间不足,JVM会引发堆增长; 
如堆增长后仍然空间不足,则会重复3和4,直至堆空间增长至最大值; 
如果当堆的尺寸增长至最大值后仍然无法容纳新的Java对象,则导致JVM无法为新对象创建内存区域,出现“out of memory错误”。 
   
堆内存结构图
 
[img][/img]
 
当创建一个对象时,JVM首先在堆中为对象分配空间,然后在线程堆栈中压栈一个变量,将变量指向新创建的对象,当将变量置为Null时,该变量不再引用任何对象,先前在堆中创建的对象也因此变为死亡对象。
 
1
2
3
Person p = new Person();

p = null;
 
灵活的使用虚引用可以避免反复创建对象造成的性能浪费,只有当完全垃圾回收发生时,JVM才会回收虚引用指向的对象。
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
A a = new A();

a.test();

SoftReference sr = new SoftReference(a);

a = null;

if (sr != null) {  

    a = (A)sr.get();   

    a.test();

}

else {     

    a = new A();   

    a.test();  

    a = null;  

    sr = new SoftReference(a);

}
   
内存溢出
 
内存溢出通常发生在JVM无法为新创建的Java对象分配足够的内存空间情况下,针对不同区域的内存分配情况,内存溢出分为以下几种类型。
 
年老代溢出(java.lang.OutOfMemoryError: Java heap space),这种内存溢出是最常见的情况之一,产生的原因可能是:
 
 
堆尺寸设置过小或年青代所占堆内存的比例设置过大(Xms/Xmx, XX:NewRatio); 
程序所申请内存过大,有的程序会申请几十乃至几百兆内存(如不分页的Grid导出),此时JVM也会因无法申请到资源而出现内存溢出,对此首先要找到相关功能模块,然后交予程序员修改,可以使用JProbe之类的Java调优工具对问题模块进行定位。 
当Java对象使用完毕后没有及时销毁(内存泄漏),使得JVM认为他还是活跃的对象而不进行回收,这样累计占用了大量内存而无法释放。可以使用JProbe之类的Java调优工具对Dump出来的崩溃的JVM进程进行静态分析,找到泄漏点进行修改。 
 
永久代溢出(java.lang.OutOfMemoryError: Perm Gen):
 
系统需要加载大量的类或在类中大量使用静态成员时可能会导致永久代溢出,解决方法是加大永久代尺寸(XX:PermSize和XX:MaxPermSize),或修改代码,将静态成员改为普通成员。
 
堆栈溢出(java.lang.StackOverFlowException):
 
当程序使用大的递归或循环算法时可能会造成堆栈溢出,比如由于程序设计不当出现死循环或次数超过几千次的递归,解决方法是修改代码或加大线程的堆栈尺寸(Xss)。
   
内存优化最佳实践
 
Java内存优化主要是指对JVM的内存模型进行优化的过程,主要手段是通过JVM参数来调整JVM内存的运行时状态,通常JVM参数分为三大类:
 
 
标准参数(-):所有的JVM实现都必须实现这些参数的功能,而且向后兼容; 
非标准参数(-X):所有的JVM实现都必须实现这些参数的功能,但不保证向后兼容; 
非Stable参数(-XX):此类参数各个JVM实现会有所不同,将来可能会随时取消,需要慎重使用; 
 
下面我们将针对常见的涉及性能调优的JVM参数逐个进行讲解。
 
了解JVM的两种启动模式:调试模式和生产模式(-client -server):
 
 
设置JVM使用client模式,特点是启动速度比较快,但运行时性能和内存管理效率不高,通常用于客户端应用程序或者PC应用开发和调试。  
设置JVM使server模式,特点是启动速度比较慢,但运行时性能和内存管理效率很高,适用于生产环境。 
在具有64位能力的Java环境下将默认启用生产模式,而忽略-client参数。 
 
将堆内存的初始值与最大值设置为相同(-Xms -Xmx):
 
将堆内存的初始值与最大值设置为相同意味着禁用堆增长,通过前面学习的内容可以知道,每当发生一次堆增长时,至少会伴随两次的完全垃圾回收(前后各一次),如果一次增长不能满足要求时,情况会变的更糟。所以记住这一条,永远将堆内存的初始值与最大值设置为相同。
 
注意,当堆内存的初始值与最大值设置为相同时,-XX:MinHeapFreeRatio 和 -XX:MaxHeapFreeRatio 参数将失效。
 
合理的分配年青代与年老代的大小(-Xmn -XX:NewRatio -Xmn):
 
当使用Spring Ioc/AOP、Hibernate或其它的需要在系统中维持大量缓存对象的框架时,应当将年老代所占比例加大。通常来讲缓存对象会长期占用年老代,设置一个大的年老代可以有效的降低次级垃圾回收和完全垃圾回收的发生概率,JVM参数中没有直接设置年老代尺寸的参数,但可以通过减小年青代的尺寸来变相加大年老代,事实上现在流行的J2EE框架都大量使用缓存技术来提高系统性能,所以加大年老代的尺寸是一个通用的JVM调优方法,通过一个设置得当的内存参数加上优秀的架构作支撑,理论上可以100%避免次级垃圾回收和完全垃圾回收的发生。-Xmn是设置固定的年青代大小, -XX:NewRatio 是设置年青代在整个堆内存中占的比例;
 
当不使用前面提到的框架,很少或不使用缓存对象时,应当设置一个大的年青代,一个大的年青代带来的好处是降低初级垃圾回收的发生(只是降低,永远不要期望避免初级垃圾回收的发生),象基于JSP/JavaBean模式开发的小型WEB应用或通过精确控制的小型的Java桌面应用,可以考虑使用这种配置方案,事实上,系统80%以上的GC消耗都来自于次级垃圾回收和完全垃圾回收,减少次级垃圾回收和完全垃圾回收带来的性能提高要远远高于降低初级垃圾回收带来的效果。
 
根据项目规模适当的设置永久代的大小(-XX:PermSize -XX:MaxPermSize):
 
永久代被用来存储类和类的静态成员,当系统需要加载大量的类或在类中大量使用静态成员(静态方法或变量)时(工具类?),应设置一个大的永久代。事实上一个系统稳定运行一段时间后,永久代的大小是可以评估出来的(需要借助一些调优工具)。永久代设的过大其实对系统性能没有提升,反而因为过多的占用了堆空间而限制了年青代和年老代的大小,如果你的堆内存总量在1.5G,那么将永久代的尺寸设置为256M吧。
 
调整新生代与复活代的尺寸比例(-XX:SurvivorRatio):
 
这是一个高级的调优内容,确保除非确实需要,否则轻易不要使用这个参数。通过前面学习的内容我们知道,初级垃圾回收只发生在新生代溢出的情况下(只此一条),因此加大新生代在年青代中所占的比例可以更明显的降低初级垃圾回收的发生概率(试想一下,如果将整个堆空间全部设置为新生代会发生什么情况?)。 这么作带来的负效应是使复活代变的很小(回想一下复活代的作用),这样将导致复活带无法容纳足够多的“准死亡对象”从而造成大量本应该在初级垃圾回收中销毁的对象被迫提前进入到年老代中,最终的结果会导致次级垃圾回收的频繁发生而造成不必要的性能损耗。反之,如果将复活代所占的比例调大,那么准死亡对象的问题就不会发生了,但由于新生代太小,造成频繁的初级垃圾回收,对系统性能也是不利的。Sun hotspot JVM默认的新生代与复活代的比例为50%(由于存在活动复活代和非活动复活代两个大小一样的复活代,所以一个复活代占用年青代的比例为25%)。
 
设置一个合理的垃圾存活年龄(-XX:MaxTenuringThreshold):
 
这个参数网上很少被提到,其实它对系统性能的影响还是很大的。根据Sun的官方文档,这个参数用来设置Java对象在复活代内“熬”过几次初级垃圾回收后才会被移入年老代。当一个系统中存在比较多的“准缓存对象”时应该考虑将这个值设大。准缓存对象是这样一类对象,它会在内存中存在一段时间,但总会有新的缓存对象来替换它(频繁变化的数据库缓存?AA10?)或它的存在具有一定的时效性(一个繁忙的WEB系统的Session对象?Request?),那么为了尽可能的优化系统性能,我们应该让JVM在初级垃圾回收时就能够回收这些对象,而不应是次级垃圾回收。当然将这个值设的太大会导致一些真正的缓存对象长时间留在复活区内从而加重初级垃圾回收的压力,而且,当复活区溢出时,总是会将年龄最大的对象移动到年老区,不管你将这个值设的有多大,补充一点,将这个参数设置为0代表你要禁用复活区,系统默认值是15。
 
强制JVM在进行完全垃圾回收前先进行初级垃圾回收(-XX:+ScavengeBeforeFullGC):
 
望名知义,如果初级垃圾回收成功回收了足够的可用空间,就不用再进行完全垃圾回收了,但如果初级垃圾回收没有回收到足够的可用空间,则完全垃圾回收照样会进行,这时反而白白消耗了一次初级垃圾回收。
 
设置线程的堆栈大小(-Xss)
 
当且只当你开发的是一个“算法密集”型的系统(视频压缩解压缩软件?)时,可以考虑加大堆栈尺寸。其它任何时候当看到“Stack Over   Flow”异常时首先考虑你的代码是否出现的死循环。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
 
挑战极限性能:禁用垃圾回收器(-Xnoclassgc):
 
设置该参数后将禁用JVM使用任何垃圾回收器,禁用垃圾回器意味着系统性能将达到极限值,但后果是必须由我们自己来负责回收垃圾,不幸的是Java语言并没有提供给我们任何手动回收对象的方法。所以只有在一些极端的环境下才考虑使用这个参数来换取最高的系统性能,比如象JNDI这一类只会不断增加对象,而“绝对”不会出现垃圾的情况。
 
选择一个合适的垃圾回收器(-XX:UseParallelGC -XX:-UseConcMarkSweepGC -XX:-UseSerialGC):
 
Java5.0为我们提供了三种可用的垃圾回收器:串行回收器、并行回收器和并发回收器。串行回收器性能最差,但在单核处理器和小尺寸堆上会有意外表现。并行回收器具有最大回收速度,但在回收过程中会暂停应用程序。并发回收器速度较慢但却不会暂停程序(不暂停不代表不减慢)。Java5.0默认在年青代使用并行回收器以换取最大的吞吐量,在年老代使用并发收集器来减少对应用程序的暂停时间。在年青代永远使用并行回收器吧,因为初级回收几乎是在每时每刻发生,使用速度最快的并行回收器永远是你的最佳选择。如果你面对的是一个繁忙的系统,而频繁的年老代回收正在严重拖慢系统性能,这时考虑使用-XX:-UseParallelGC参数强制JVM在年老代使用并行收集器,此时快速的并行收集器会为你带来可观的性能提升。否则,使用系统默认的并发收集器吧。
 
跟踪JVM调试信息(-verbose:gc -verbose:class -Xprof -Xloggc:file):
 
 
-verbose:gc:输出每次GC的相关情况,输出形式:[GC 118250K->113543K(130112K), 0.0094143 secs] [Full GC 121376K-  >10414K(130112K), 0.0650971 secs]。 
-verbose:class:输出JVM载入类的相关信息,当JVM报告说找不到类或者类冲突时可此进行诊断。 
-Xprof:打开JVM调试信息,跟踪正在运行的程序,并将跟踪数据在标准输出输出,适合于开发环境调试 。 
-Xloggc:file:与-verbose:gc功能类似,只是将每次GC事件的相关情况记录到一个文件中。 
   
内存参数设计示例
 

  -server

  -XX:UseParallelGC

  -Xprof -verbose:gc

  -Xmx1550m

  -Xms1550m

  -Xss128k

  -Xns512m

  -XX:NewRatio=4

  -XX:SurvivorRatio=4

  -XX:MaxPermSize=256m

  -XX:MaxTenuringThreshold=10

  -XX:NewRatio=4
 
 
-server:使用产品模式启动JVM; 
-XX:UseParallelGC:在年老代使用并行回收器; 
-Xmx1550m Xms1550m:将堆的最小值与最大值设置为相同; 
-Xns512m:设置年轻代大小为512M。整个堆大小=年轻代+年老代+持久代。 
-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5;
-XX:SurvivorRatio=4:设置年轻代中新生代与复活代的大小比值。设置为4,则两个复活代与一个新生代的比值为2:4,一个复活代占整个年轻代的1/6; 
-XX:MaxPermSize=128m:设置永久代大小为256M; 
-XX:MaxTenuringThreshold=10:设置垃圾最大年龄,对象在复活区内存活10次后被移动到年老区; 
-Xss128k:设置每个线程的堆栈大小为128K; 
-Xprof -verbose:gc:打开JVM调试信息,打开垃圾回收调试信息。 
 
请尊重原创,转载务必注明出处。
http://www.coolfancy.com/log/6.html#link2

 

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics