读书笔记——阅深入理解Java虚拟机-周志明上

读书笔记——阅深入理解Java虚拟机(上)

java发展史

java技术体系 :
sun官方定义
1. java 程序设计语言
2. 各种硬件平台上的java虚拟机
3. class文件格式
4. java API 类库
5. 来自商业机构和开源社区等第三方类库

java对象访问

对象访问在Java 语言中无处不在,即是最简单的访问也涉及Java栈、java堆、方法区三个重要的内存区域中

Object obj = new Object();

Object obj 反映到Java栈(Java VM Stack)的本地变量表,作为一个reference类型数据出现。
new Object() 反映到Java堆中,形成一块存储了Object类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在Java堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据测存储在方法区中。

通过reference类型如何访问Java堆中的对象?主流访问的方式有两种

  • 使用句柄访问方式

    Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息
    《读书笔记——阅深入理解Java虚拟机-周志明上》

  • 使用直接指针访问方式

    Java堆对象中的布局就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址
    《读书笔记——阅深入理解Java虚拟机-周志明上》

优势对比

  • 句柄访问方式:最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改
  • 直接指针访问方式:最大的好处就是速度快,它节省了一次指针定位的时间开销。

自动内存管理机制

垃圾收集(Garbage Collection, GC),不是Java语言的伴生物。事实上,GC在1960年诞生于MIT的Lisp是第一门真正使用内存动态分配的和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC需要完成的三件事情

  • 那些内存需要回收?
  • 什么时候回收?
  • 如何回收?

引用计算算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计算器就减1;任何时刻计数器为0的对象就是不可能再被使用的。但是,Java语言中没有选用引用计数算法来管理内存,其主要原因是它很难解决对象之间的相互循环引用问题

如下例子

package chapter3;

/** * 引用计数算法的缺陷 * * testGC()方法执行后,objA和objB会不会被GC呢 * * -XX:+PrintGC 输出GC日志 * -XX:+PrintGCDetails 输出GC的详细日志 * -XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式) * -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800) * -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息 * -Xloggc:../logs/gc.log 日志文件的输出路径 * * @author Administrator * */
public class ReferenceCountingGC {

    public Object instance = null;

    /** * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过 */
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();

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

        objA = null;
        objB = null;

        //假设在这行发生GC,那么objA和objB是否能被回收?
        System.gc();
    }

}

根搜索算法

这个算法的基本思路就是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路劲称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(换句话说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,这样的对象被判定为是可回收的。如图所示,对象object 5、object 6、object 7虽然互相关联,但是它们带GC Roots是不可达的。
在JAVA语言中,可以当做GC roots的对象有以下几种:

  • 虚拟机栈中的引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象
  • 本地方法栈中JNI的引用的对象。

《读书笔记——阅深入理解Java虚拟机-周志明上》

在根搜索算法中,不可达的对象,也并非是“非死不可”,要真正宣告一个对象死亡,至少要经历2两次标记过程
– 第一次标记,加入名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的,低优先级的Finalizer线程执行finalize()方法,该方法是对象逃脱死亡命运的最后一次机会(只要重新与引用链上的任何一个对象建立关联即可)。
– 第二次标记,就是直接加入“即将回收”的集合里

一次对象自我拯救的演示

package chapter3;

/** * 此代码演示了两点 * 1、对象可以在被GC时自我拯救 * 2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次 * * @author zkl * */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive(){
        System.out.println("i am still alive");
    }

    @Override
    protected void finalize() throws Throwable {
        // TODO Auto-generated method stub
        super.finalize();
        System.out.println("finalize method executed");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

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

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();

        // 因为Finalize 方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);

        if(SAVE_HOOK == null){
            System.out.println("no i am dead");
        }
        else {
            SAVE_HOOK.isAlive();
        }

        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();

        // 因为Finalize 方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);

        if(SAVE_HOOK == null){
            System.out.println("no i am dead");
        }
        else {
            SAVE_HOOK.isAlive();
        }
    }
}

方法区的回收

大多数认为方法区(或者HotSpot虚拟机中的永久代PermGen,jdk1.8之后被元数据去区metaspace 代替)是没有垃圾收集的,其实被不是没有垃圾,只是在方法区进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收75%~95%的空间,二永久代的垃圾收集效率低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类

如何判断一个常量是否是“废弃常量”??
以常量池中的字面量的回收为例,假如一个字符串“abc”已经进入常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用这个字面量,如果这时候回收,而且必要的话,这个“abc”常量会被系统“请”出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

判定一个类是否是“无用的类”。类需要同时满足下面3个条件才能算是“无用的类”
1.该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
2.加载该类的ClassLoader已经被回收
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然回收。是否回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。

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

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

  2. 软引用(Soft Reference)来描述一些还有用,但并非必须的对象。对于软引用关联这的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才回抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。

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

  4. 虚引用(Phantom Reference)也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

垃圾收集算法

  1. 标记 – 清除(Mark – Sweep)算法

最基础的收集算法,是现代垃圾回收算法的思想基础,该算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象,因此未被标记的对象就是未被引用的垃圾对象。然后在清除阶段,清除所有未被标记的对象。这种方法可以解决循环引用的问题,只有两个对象不可达,即使它们互相引用也无济于事。也是会被判定位不可达对象。

如下图所示,简单描述了使用标记清除法对一块连续的内存空间进行回收。
从根节点开始(在这里仅显示了两个根节点),所有的有引用关系的对象均被标记为存活对象(箭头表示引用)。从根节点起,不可达对象均为垃圾对象。在标记操作完成后,系统回收所有不可达对象。

《读书笔记——阅深入理解Java虚拟机-周志明上》

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致,当程序在以后的运行过程中需要分配较大对象是无法知道足够的连续内存而不得提前触发一次垃圾收集动作。

  • 复制算法

    算法思想:将原有的内存空间分为两块相同的存储空间,每次只使用一块,在垃圾回收时,将正在使用的内存块中存活对象复制到未使用的那一块内存空间中,之后清除正在使用的内存块中的所有对象,完成垃圾回收。

    如果系统中的垃圾对象很多,复制算法需要复制的存活对象就会相对较少(适用场景)。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。而且,由于存活对象在垃圾回收过程中是一起被赋值到另一块内存空间中的,因此,可确保回收的内存空间是没有碎片的。(优点)

    但是复制算法的代价是将系统内存空间折半,只使用一半空间,而且如果内存空间中垃圾对象少的话,复制对象也是很耗时的,因此,单纯的复制算法也是不可取的。(缺点)

    A、B两块相同的内存空间(原有内存空间折半得到的两块相同大小内存空间AB),A在进行垃圾回收,将存活的对象复制到B中,B中的空间在复制后保持连续。完成复制后,清空A。并将空间B设置为当前使用内存空间。

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    在java中的新生代串行垃圾回收器中,使用了复制算法的思想,新生代分为eden空间、from空间和to空间3个部分,其中from和to空间可以看做用于复制的两块大小相同、可互换角色的内存空间块(同一时间只能有一个被当做当前内存空间使用,另一个在垃圾回收时才发挥作用),from和to空间也称为survivor空间,用于存放未被回收的对象。

    【新生代对象】:存放年轻对象的堆空间,年轻对象指刚刚创建,或者经历垃圾回收次数不多的对象。
    【老年代对象】:存放老年对象的堆空间。即为经历多次垃圾回收依然存活的对象。

    在垃圾回收时,eden空间中存活的对象会被复制到未使用的survivor空间中(图中的to),正在使用的survivor空间(图中的from)中的年轻对象也会被复制到to空间中(大对象或者老年对象会直接进入老年代,如果to空间已满,则对象也会进入老年代)。此时eden和from空间中剩余对象就是垃圾对象,直接清空,to空间则存放此次回收后存活下来的对象。

    优点:这种复制算法保证了内存空间的连续性,又避免了大量的空间浪费。
    注意:复制算法比较适用于新生代。因为在新生代中,垃圾对象通常会多于存活对象,算法的效果会比较好。

  • 标记 – 整理算法

    **复制算法的高效性是建立在存活对象少、垃圾对象多的情况下,这种情况在新生代比较常见,
    但是在老年代中,大部分对象都是存活的对象,如果还是有复制算法的话,成本会比较高。因此,基于老年代这种特性,应该使用其他的回收算法。**
    标记 – 整理算法是老年代的回收算法,它在标记清除算法的基础上做了优化。(回忆一下,标记清除算法的缺点,垃圾回收后内存空间不再连续,影响了内存空间的使用效率。。。)

    和标记清除算法一样,标记-整理算法 也首先从根节点开始,对所有可达的对象做一次标记,但之后,它并不是简单的清理未标记的对象,而是将所有的存活对象压缩到内存空间的一端,之后,清理边界外所有的空间。
    这样做避免的碎片的产生,又不需要两块相同的内存空间,因此性价比高。

    通过根节点标记出所有的可达对象后,沿着虚线进行对象的移动,将所有的可达对象移到一端,并保持他们之间的引用关系,最后,清理边界外的空间。
    《读书笔记——阅深入理解Java虚拟机-周志明上》
    标记压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片的整理。

  • 分代收集算法

    分代算法思想:将内存空间根据对象的特点不同进行划分,选择合适的垃圾回收算法,以提高垃圾回收的效率。

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    通常,java虚拟机会将所有的新建对象都放入称为新生代的内存空间。

    新生代的特点是:对象朝生夕灭,大约90%的对象会很快回收,因此,新生代比较适合使用复制算法。

    当一个对象经过几次垃圾回收后依然存活,对象就会放入老年代的内存空间,在老年代中,几乎所有的对象都是经过几次垃圾回收后依然得以存活的,因此,认为这些对象在一段时间内,甚至在程序的整个生命周期将是常驻内存的。

    老年代的存活率是很高的,如果依然使用复制算法回收老年代,将需要复制大量的对象。这种做法是不可取的,根据分代的思想,对老年代的回收使用标记清除或者标记压缩算法可以提高垃圾回收效率。

    注意:分代的思想被现有的虚拟机广泛使用,几乎所有的垃圾回收器都区分新生代和老年代。

  • 垃圾收集器

    垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法)的具体实现,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,主要介绍HotSpot虚拟机中的垃圾收集器。

    垃圾收集器组合

    JDK7/8之后,HotSpot虚拟机所有收集器及组合(连线),如下图
    《读书笔记——阅深入理解Java虚拟机-周志明上》
    有7种不同分代的收集器:

        Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;
    

    而它们所处区域,则表明其是属于新生代收集器还是老年代收集器:

    新生代收集器:Serial、ParNew、Parallel Scavenge;
    老年代收集器:Serial Old、Parallel Old、CMS;
    整堆收集器:G1;

    两个收集器间有连线,表明它们可以搭配使用:

        Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
    

    Minor GC和Full GC的区别
    (A)、Minor GC

       又称新生代GC,指发生在新生代的垃圾收集动作;
    
       因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;
    

    (B)、Full GC

       又称Major GC或老年代GC,指发生在老年代的GC;
    
       出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);
    
      Major GC速度一般比Minor GC慢10倍以上;
    

    下面只做简单的介绍

    1、Serial收集器

    Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;
    JDK1.3.1前是HotSpot新生代收集的唯一选择;
    

    特点

    针对新生代;
    
    采用复制算法;
    
    单线程收集;
    
    进行垃圾收集时,必须暂停所有工作线程,直到完成;            
    
    即会"Stop The World";
    
    Serial/Serial Old组合收集器运行示意图如下:
    

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    2、ParNew收集器

    ParNew垃圾收集器是Serial收集器的多线程版本。
    

    特点

    除了多线程外,其余的行为、特点和Serial收集器一样;
    
    如Serial收集器可用控制参数、收集算法、Stop The World、内存分配规则、回收策略等;
    
    两个收集器共用了不少代码;
    
    ParNew/Serial Old组合收集器运行示意图如下:
    

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    3、Parallel Scavenge收集器

    Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)。
    

    特点

    (A)、有一些特点与ParNew收集器相似

    新生代收集器;
    
    采用复制算法;
    
    多线程收集;
    

    (B)、主要特点是:它的关注点与其他收集器不同

    CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;
    
    而Parallel Scavenge收集器的目标则是达一个可控制的吞吐量(Throughput);
    设置参数
    
    Parallel Scavenge收集器提供两个参数用于精确控制吞吐量:
    

    (A)、”-XX:MaxGCPauseMillis”

    控制最大垃圾收集停顿时间,大于0的毫秒数;
    
    MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降;
    
    因为可能导致垃圾收集发生得更频繁;
    

    (B)、”-XX:GCTimeRatio”

    设置垃圾收集时间占总时间的比率,0<n<100的整数;
    
    GCTimeRatio相当于设置吞吐量大小;
    
    垃圾收集执行时间占应用程序执行时间的比例的计算方法是:
    
    1 / (1 + n)
    
    例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5%--1/(1+19);
    
    默认值是1%--1/(1+99),即n=99;
    

    垃圾收集所花费的时间是年轻一代和老年代收集的总时间;

    如果没有满足吞吐量目标,则增加代的内存大小以尽量增加用户程序运行的时间;

    此外,还有一个值得关注的参数:
    

    (C)、”-XX:+UseAdptiveSizePolicy”

      开启这个参数后,就不用手工指定一些细节参数,如:
    
      新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等;
    
      JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs);    
    
      这是一种值得推荐的方式:
    
      (1)、只需设置好内存数据大小(如"-Xmx"设置最大堆);
    
      (2)、然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
    
      (3)、那些具体细节参数的调节就由JVM自适应完成;        
    
      这也是Parallel Scavenge收集器与ParNew收集器一个重要区别;
    

    4、Serial Old收集器

    Serial Old是 Serial收集器的老年代版本;
    

    特点

    针对老年代;
    
    采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
    
    单线程收集;
    
    Serial/Serial Old收集器运行示意图如下:
    

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    5、Parallel Old收集器

    Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;
    
    JDK1.6中才开始提供;
    

    特点

    针对老年代;
    
    采用"标记-整理"算法;
    
    多线程收集;
    
    Parallel Scavenge/Parallel Old收集器运行示意图如下:
    

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    6、CMS收集器

    并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器;
    
    在前面ParNew收集器曾简单介绍过其特点;
    

    特点

    针对老年代;
    
    基于"标记-清除"算法(不进行压缩操作,产生内存碎片);            
    
    以获取最短回收停顿时间为目标;
    
    并发收集、低停顿;
    
    需要更多的内存(看后面的缺点);
    
    
    是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;
    
    第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;
    

    CMS收集器运作过程

      比前面几种收集器更复杂,可以分为4个步骤:
    

    (A)、初始标记(CMS initial mark)

      仅标记一下GC Roots能直接关联到的对象;
    
      速度很快;
    
      但需要"Stop The World";
    

    (B)、并发标记(CMS concurrent mark)

      进行GC Roots Tracing的过程;
    
      刚才产生的集合中标记出存活对象;
    
      应用程序也在运行;
    
      并不能保证可以标记出所有的存活对象;
    

    (C)、重新标记(CMS remark)

      为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;
    
      需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
    
      采用多线程并行执行来提升效率;
    

    (D)、并发清除(CMS concurrent sweep)

      回收所有的垃圾对象;
    
      整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;
    
      所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;
    
      CMS收集器运行示意图如下:
    

    《读书笔记——阅深入理解Java虚拟机-周志明上》

    CMS收集器3个明显的缺点

    1. 对CPU资源非常敏感
    2. 无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
    3. 产生大量内存碎片
    

    7、G1收集器

    G1(Garbage-First)是JDK7-u4才推出商用的收集器;
    

    特点

    (A)、并行与并发

    能充分利用多CPU、多核环境下的硬件优势;
    
    可以并行来缩短"Stop The World"停顿时间;
    
    也可以并发让垃圾收集与用户程序同时进行;
    

    (B)、分代收集,收集范围包括新生代和老年代

    能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
    
    能够采用不同方式处理不同时期的对象;
    
    虽然保留分代概念,但Java堆的内存布局有很大差别;
    
    将整个堆划分为多个大小相等的独立区域(Region);
    
    新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;
    

    (C)、结合多种垃圾收集算法,空间整合,不产生碎片

    从整体看,是基于标记-整理算法;
    
    从局部(两个Region间)看,是基于复制算法;
    
    这是一种类似火车算法的实现;
    
    都不会产生内存碎片,有利于长时间运行;
    

    (D)、可预测的停顿:低停顿的同时实现高吞吐量

    G1除了追求低停顿处,还能建立可预测的停顿时间模型;
    
    可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
    
        原文作者:java虚拟机
        原文地址: https://blog.csdn.net/Stu_zkl/article/details/82714622
        本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
    点赞