文章目录

  • JVM - 进入Java虚拟机的真实世界
    • 1.探索虚拟机的内存区域
      • 1.1 运行时数据区
      • 1.2 程序计数器(Program Counter Register)
        • 1.2.1 程序计数器是什么?
        • 1.2.2 为什么使用程序计数器?
      • 1.3 虚拟机栈(VM Stack)
        • 1.3.1 虚拟机栈是什么?
        • 1.3.2 为什么使用虚拟机栈?
        • 1.3.3 方法是如何调用的?
        • 1.3.4 Java虚拟机栈中的异常
      • 1.4 本地方法栈(Native Method Stack)
        • 1.4.1 本地方法栈是什么?
        • 1.4.2 为什么使用本地方法栈?
      • 1.5 Java堆(Heap)
        • 1.5.1 Java堆是什么?
        • 1.5.2 初识Java堆
      • 1.6 方法区(Method Area)
        • 1.6.1 方法区是什么?
        • 1.6.2 方法区和永久代(PermGen)的渊源
        • 1.6.3 JDK1.8开始为什么使用元空间(MetaSpace)代替永久代?
        • 1.6.4 永久代和元空间的配置
      • 1.7 运行时常量池(Runtime Constant Pool)
        • 1.7.1 运行时常量池是什么?
        • 1.7.2 运行时常量池其实就在我们身边
        • 1.7.3 运行时常量池是否也会发生异常
      • 1.8 直接内存(Direct Memory)

JVM - 进入Java虚拟机的真实世界

相信对Java编程有了一定程度了解的同学,多多少少都已经听说过、了解过Java虚拟机。就算你还未开始学习Java编程但已经打算计划去学习,那你也肯定听说过一本书《深入理解Java虚拟机 JVM高级特性与最佳实践 》。在我当时正计划踏入Java这个大家庭的时候,我也提前买了一堆书籍,其中就有它。直到两年后,我对自己技术的不满足并且制定了一系列计划之后,我才重新翻开这本书。过去的我对JVM的了解仅仅只是冰山一角,从这里开始我们一起慢慢去进入Java虚拟机真实的世界。

相信大家学习Java那就一定对Java语言为何保持着优势并且经久不衰十分清楚,其中的一个配件功不可没-JVM,这是塑造了Java代码一处编译、处处运行的根本。如果对其他编程语言,例如C或者C++有了解的同学应该能够很清楚,在C和C++当中对于内存的管理,开发者拥有至高无上的权利,但同时开发者也需要对其中每一对象从创建到销毁都进行维护。

而对于Java开发者来说,虚拟机的自动内存管理机制能够在大部分情况下都帮助我们管理好我们所使用的内存,对于已经十分成熟的虚拟机技术很少会出现内存泄漏以及内存溢出的问题,这对于我们Java开发者来说简直就是一个十分愉悦的事情。但与此同时,这样完善的内存管理机制也是一把利刃。一旦出现内存泄漏或者溢出等问题,若我们对虚拟机真正的管理机制一知半解甚至不清楚,那问题将会十分难以解决甚至是致命的。

这里先把整个JVM系列的代码和脑图分享给大家。代码地址:【springboot-jvm】,百度脑图:【JVM】。

1.探索虚拟机的内存区域

 当我们运行一个Java程序的时候,JVM会将Java程序在运行过程中所用到的内存进行管理起来,将其分成若干个不同的数据区域。每个区域都有其独特定义、用途以及特性。这里我们先来看一下程序运行时数据区是如何分配的。

1.1 运行时数据区

 对于运行时数据区的分配,从JDK8开始还进行了一些改变。这里所展示的运行时数据区的结构是以我们平时使用的HotSpot虚拟机为例,其他虚拟机在结构上可能存在稍微的偏差,对整体理解不会造成影响。

JDK1.8之前

JDK1.8

 这里我们可以很清晰地看到JDK1.8起彻底将方法区移除并替换成了直接内存中新增加的元空间,另外把运行时常量池放在了堆内分配。
 另外每个数据区分布也不同,主要区别是线程共享以及线程私有。这里我们大概看一下,下面会对每个数据区单独介绍。

  1. 线程共享:堆、方法区
  2. 线程隔离/私有:虚拟机栈、本地方法栈、程序计数器

1.2 程序计数器(Program Counter Register)

1.2.1 程序计数器是什么?

程序计数器实际上是一块较小的内存空间,我们可以把它看作是当前线程所执行的字节码的行号指示器。字节码解释器运行时主要就是通过修改当前线程的程序计数器的值,来依次读取需要执行的字节码指令,我们程序中的分支、循环、跳转、异常处理、线程恢复等基础功能都是依赖程序计数器来完成的。

1.2.2 为什么使用程序计数器?

 我们都知道线程是一个独立的执行单元,由CPU所控制执行的。对操作系统有了解的同学应该听说过时间片,其实在我们应用程序运行过程中每个线程都被会分配一个时间段,也就是CPU分配给各个程序运行的时间。当我们同时运行多个应用程序时,其实本质上是每个线程不停轮流切换去使用CPU的资源,看似是多个应用程序在同时执行,其实本质上若只有一个CPU(内核),则一次只能处理一个时间片中的指令。

 所以为了线程来回切换时能够恢复到上一次正确的执行位置,每个线程都会自己维护一个独立的程序计数器,独立存储且不受其他线程的影响,是一块线程私有的内存空间。

 这里我们根据实际情况来做一个讲解。这里我们先定义一个简单的User类。

public class User{private Integer age;private String name;public Integer getAge(){return age;}public String getName(){return name;}
}

 我们对其进行编译之后,通过javap -l查看编译后的字节码文件。

 这里可以看到每一个方法上开始都会有对应的行号,这就是我们程序计数器所需要记录的数据。

 例如我们在时间片1时,CPU将资源分配给了线程1,此时线程1执行到了getAge()方法的位置,CPU将时间片分配给了线程2,此时线程1的程序计数器就会记录当前getAge()所在行。并将时间片切换至线程2。

 线程2在时间片2内又执行到了getName()的位置,此时线程2的程序计数器也会将getName()的位置记录下来,并切换到时间片3。假设时间片分配是均匀的,则时间片3时线程1恢复执行,这时就需要使用到之前线程1的程序计数器所记录的位置用以恢复到上一次线程1所执行的字节码所在行。多个线程之间就是这样来回切换运行的。

 这里因为线程正在执行的是一个Java方法,所以这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。

 由于程序计数器是JVM默认分配的不由开发者控制,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。所以程序计数器也是唯一一个不会出现OutOfMemoryError的内存区域,

1.3 虚拟机栈(VM Stack)

1.3.1 虚拟机栈是什么?

 Java 虚拟机栈与程序计数器一样,也是线程私有的。它的生命周期也和线程生命周期相同,每个线程都有各自的Java虚拟机栈,并随着线程的创建而创建,随着线程的死亡而死亡。它主要负责作用于Java方法执行的一块内存区域,每次方法调用都是通过栈传递的。

 Java内存在粗略上可以分为堆内存(Heap)以及栈内存(Stack),栈内存指的就是这里说的虚拟机栈。

1.3.2 为什么使用虚拟机栈?

 实际上,Java 虚拟机栈是由一个个栈帧(Stack Frame)组成的。每个栈帧中都包括:局部变量表、操作数栈、动态链接、方法出口等信息。每个方法在执行的同时都会创建一个栈帧,方法从调用到执行再到完成的过程,就对应着一个栈帧在虚拟机栈中的入栈到出栈的过程。

 而其中的局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

1.3.3 方法是如何调用的?

 这里我们先贴一段十分简单的代码。

package com.ithzk.dataSpace;/*** @author hzk* @date 2019/10/25*/
public class Stack {private static void methodA(){System.out.println("methodA");}private static void methodB(){methodA();System.out.println("methodB");}public static void main(String[] args){methodB();}
}

 一段很简单的代码,大家都知道这里会先输出methodA再输出methodB,那么对于Java虚拟机来说是如何执行的呢。

 当我们调用Main方法时会为其创造一个栈帧1压入虚拟机栈中。在Main方法执行过程中调用了方法B,此时虚拟机栈又会为methodB创造一个栈帧入栈,接着调用方法A同理。

 可以看出虚拟机栈和我们平时所接触的数据结构中的栈十分相似,Java虚拟机栈中保存的主要内容就是栈帧,每个方法的调用都会创建一个对应的栈帧压入虚拟机栈中,每个方法执行结束都会将对应的栈帧弹出。

 在Java方法中主要有两种返回方式可以使栈弹出:

  1. Return
  2. 抛出异常

1.3.4 Java虚拟机栈中的异常

 Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  1. StackOverFlowError:若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java虚拟机栈的最大深度时就抛出StackOverFlowError异常。
  2. OutOfMemoryError:若Java虚拟机栈的内存大小允许动态扩展,当线程请求栈时内存完全耗尽无法动态扩容时此时抛出OutOfMemoryError异常。
package com.ithzk.dataSpace;/*** @author hzk* @date 2019/10/25*/
public class Stack {private static void methodB(){methodB();System.out.println("methodB");}public static void main(String[] args){methodB();}
}

 上面这段代码中,方法B对自己重复地调用,就会创建N个栈帧去压入虚拟机栈中。因为没有终止调用的条件,所以最终线程请求的栈深度就大于虚拟机所允许的深度,就会抛出StackOverflowError异常。

1.4 本地方法栈(Native Method Stack)

1.4.1 本地方法栈是什么?

 上面我们介绍了Java虚拟机栈,而本地方法栈与其作用十分相似。它们之间的区别主要是:Java虚拟机栈为虚拟机执行Java方法(字节码)存在,而本地方法栈则为虚拟机执行所需的Native方法存在。以我们平时使用频率比较高的String为例,很多类中都提供了native方法,这些方法很多都是通过一些其他语言实现,而本地方法栈主要就是用于本地方法执行的一块内存区域。


 虚拟机规范中对于本地方法栈中方法所使用的语言、数据结构以及使用方式都没有强制性的规定,所以各种虚拟机都可以根据所需自由设计实现。像我们现在一般使用到的HotSpot虚拟机,设计团队认为其二者功能大致相同,就直接将虚拟机栈和本地方法栈合二为一。

1.4.2 为什么使用本地方法栈?

 与Java虚拟机栈相同,本地方法栈中每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法执行完成后相应的栈帧也会出栈并释放内存空间。

 与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常。

1.5 Java堆(Heap)

1.5.1 Java堆是什么?

Java堆是Java虚拟机所管理的内存中最大的一块区域,并且是所有线程共享的一块内存区域。此内存区域的唯一目的就是为了存放对象实例,几乎所有的对象实例以及数组都在Java堆中分配内存。

 因为Java堆是虚拟机中最大的一块内存区域,所以也是垃圾收集器主要管理的区域,因此也被称作GC堆(Garbage Collected Heap)
 了解垃圾回收机制的同学应该清楚,由于现在收集器基本都采用了分代垃圾收集算法,所以Java堆还能进一步细分为新生代老年代。将新生代老年代再进一步划分还能氛围EdenFrom SurvivorTo Survivor等区域。每一层更细致地划分都是为了更好地去分配或者回收内存。

1.5.2 初识Java堆

 这里我们直接创建一个简单的SpringBoot应用跑起来。我们可以通过jps或者wmic process where caption="java.exe"命令查看到我们启动服务的进程号。

 这里我们通过jdk中提供的jmap命令就可以看到我们应用程序的内存分配情况了。

 这里我们清楚地看到新生代中的EdenFrom SpaceTo Space各个区域的内存分配情况。大多数情况下,对象都会在EdenFrom区域中分配。在虚拟机进行一次新生代的垃圾回收后如若Eden区中的对象依然存活,则会将其移至To区;而From区中仍然存活的对象则会根据其年龄决定去处。每次GC后仍存活的对象年龄都会增加1,当到达一定的阈值后则会移至老年代中进行管理。我们可以通过-XX:MaxTenuringThreshold指定这个阈值。

 我们通过查阅一些资料可以了解到,根据《Java语言及虚拟机规范》中的规定,Java堆在物理上可以是一段不连续的内存空间,只要在逻辑上是连续的即可。这点和我们平时使用的磁盘空间有点类似,而当下比较主流的Java虚拟机设计也都是支持扩展的(-Xmx、-Xms等参数控制) 。若堆中不足内存去分配实例并且也无法再扩展时,也是会抛出OutOfMemoryError异常的。

1.6 方法区(Method Area)

1.6.1 方法区是什么?

 方法区也是线程共享的一块内存区域,主要用于存储已被虚拟机加载的类信息(例:类版本号、方法、接口等)、常量、静态变量、即时编译器编译后的代码等数据。虽然在《Java语言及虚拟机规范》中将方法区描述为堆的一个逻辑部分,但是它却有一个别名-Non-Heap(非堆),在某种意义上应该还是与Java堆区分开。

1.6.2 方法区和永久代(PermGen)的渊源

 仔细阅读过会发现在《Java语言及虚拟机规范》中只规定了方法区的概念和作用,并没有明确规定方法区如何实现。对于我们平时在HotSpot虚拟机上编写程序的开发者来说,其实更多的会将方法区称为永久代,这是因为HotSpot虚拟机开发团队在设计时为了省事儿不想专门给方法区编写内存管理的代码,所以将GC分代收集延用到了方法区上。

 而对于另外的虚拟机,例如JRockit、J9VM等其实并不存在永久代这个概念。我们开发时肯定都使用过日志,方法区和永久代的关系就好比slf4jlog4j,虽然我也不知道这种类比合不合适,但大家理解就可以。方法区就像slf4j仅仅是制订了一套规范,而永久代就是HotSpot虚拟机根据方法区所制订规范去进行的一个具体的实现。一个是规范、标准,另一个是实现。

 所以永久代这个概念仅仅是存在我们使用的HotSpot虚拟机中。

1.6.3 JDK1.8开始为什么使用元空间(MetaSpace)代替永久代?

 我们之前也提到了,其实HotSpot虚拟机的设计团队设计出永久代有一部分是为了省事儿,是骡子是马总要牵出来溜溜才知道。在经过岁月的冲洗后,发现这并不是一个好主意。这样的设计使开发者更容易遇到内存溢出的问题,因为永久代受限于我们设置的一个阈值,而其他的虚拟机产品只要没有越过进程可用的内存上限即可。

 所以在后续的JDK版本中,HotSpot逐渐将永久代淡化了,直到JDK1.8就完全将永久代给移除,取而代之在直接内存中新设计了一块元空间。

 由于永久代受限于JVM本身设置固定的大小,无法进行调整。而元空间使用的是直接内存,受本机可用内存的限制。我们可以使用-XX:MaxMetaspaceSize设置最大元空间大小,默认值为unlimited也就是说它只受系统内存的限制,会根据运行时应用程序所需动态调整大小。

1.6.4 永久代和元空间的配置

 在JDK1.8之前,虽然永久代会受限于设置的大小,但是我们还是可以通过一些参数去调解的,只不过对于这个大小的把控需要十分了解虚拟机的内存分配机制并根据应用程序的实际来设置才能达到利益最大化。

 我们可以通过-XX:PermSize去设置永久代的初始空间大小,还可以通过-XX:MaxPermSize去设置永久代的最大空间大小,如果在运行过程中超过这个值则会抛出java.lang.OutOfMemoryError:PermGen异常。

 相对而言,垃圾收集行为在方法区中出现频率还是比较低的,但并非数据进入方法区后就和永久代字面一样“永久存在”了,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

 从JDK1.8起HotSpot彻底移除了永久代,而使用元空间。虽然元空间使用的是直接内存,若不指定最大上限则虚拟机可能会耗尽所有的系统内存。我们也可以通过-XX:MetaspaceSize指定MetaSpace元空间的初始大小,以及-XX:MaxMetaspaceSize设置MetaSpace元空间的最大大小。

1.7 运行时常量池(Runtime Constant Pool)

1.7.1 运行时常量池是什么?

 运行时常量池属于方法区的一部分。我们的Class文件中除了有类的版本、字段、方法、接口等描述信息外,还包含用于存放编译期生成的各种字面量和符号引用等信息,这部分内容会在类加载后在运行时常量池中存放。

  1. 字面量:文本字符串、final常量、基本数据类型的值等。
  2. 符号引用:类以及结构的完全限定名、字段名称和描述符、方法名称和描述符。

1.7.2 运行时常量池其实就在我们身边

 大家有时候从一些概念上总觉得他们虚无缥缈,离我们很远。其实这些东西一直就在我们身边,我们先来看看下面这段代码。

    public static void main(String[] args){String a = "jvm";String b = "jvm";System.out.println(a == b); // trueString c = new String("jvm");System.out.println(a == c); // falseSystem.out.println(a == c.intern()); // true}

 上面这段代码可能大家都见过,面试有时也会碰见,但是开发时间久对基础不是很扎实或者刚开始接触开发的同学对其中的原理就会比较模糊,这里我们借助上图去了解就会变得十分简单。

 当我们声明ab变量时直接将字符串"jvm"给其赋值,此时对于JVM内存的分配其实是在方法区里的运行时常量池中维护了一个基于HashSet实现的StringTable全局字符串常量池,所以变量ab其实引用同一块内存空间。

 而当我们声明变量c时,采用的是new一块新的内存空间,此时会在堆中申请一块新的空间去存放"jvm"字符串内容,所以此时变量c与其他两个变量引用的内存空间不相同。但是当我们使用intern()方法后,进行内存地址比较又变为相同的,这是因为运行时常量池有一个重要的特性那就是具备动态性。由于Java语言没有要求常量一定只会在编译期间产生,运行期间也可将新的常量加入池中,String类中的intern()方法就是如此。

1.7.3 运行时常量池是否也会发生异常

 我们都知道,运行时常量池既然属于方法区的一部分,那么自然会受到方法区内存的限制。当常量池无法再申请到内存时就会抛出OutOfMemoryError异常。

 另外需要注意的是从JDK1.7开始,JVM就将运行时常量池从方法区中移除出来了,并在Java堆中开辟了一块区域用于存放运行时常量池。

1.8 直接内存(Direct Memory)

 直接内存又称堆外内存,直接内存并不属于虚拟机运行时数据区的一部分,所以JVM并不会对其内存空间进行分配和管理。

JDK1.4中新加入了NIO(New Input/Output)相关类,引入了一种基于通道(Channel)与缓存区(Buffer) 的I/O方式。它可以直接调用Native函数库对堆外内存进行直接分配,然后通过存储在Java堆中的DirectByteBuffer对象对这块内存的引用进行操作。这样可以在某些场景中显著地提高性能,因为其避免了Java堆Native堆之间来回复制数据的过程。

 直接内存的分配并不会受到Java堆的限制,但是会受到本机总内存大小以及处理器寻址空间的限制。所以这部分内存在没有合理使用的情况下也会导致OutOfMemoryError异常的出现。

ps:这里还是推荐大家有时间读一读《深入理解Java虚拟机-JVM高级特性与最佳实践 》这本书,虽然与最新的技术有一些差异,但是其中除了对JVM有详细的讲解,还对Java语言以及虚拟机的发展历史有不错的介绍。另外有时间的同学也可以去阅读一下《Java语言及虚拟机规范》,增进自己对Java虚拟机的理解和使用。

JVM - 进入Java虚拟机的真实世界相关推荐

  1. JVM: java虚拟机

    JVM: java虚拟机 jvm运行我们编写的.java文件转换后的.class文件 问题一 :Class在本地磁盘上 如何记载到jvm中 问题二:jvm又是如何加载java程序所使用的系统类(系统j ...

  2. JVM(Java虚拟机)详解(JVM 内存模型、堆、GC、直接内存、性能调优)

    JVM(Java虚拟机) JVM 内存模型 结构图 jdk1.8 结构图(极简) jdk1.8 结构图(简单) JVM(Java虚拟机): 是一个抽象的计算模型. 如同一台真实的机器,它有自己的指令集 ...

  3. java jvm目录,JVM(Java虚拟机)中过程工作目录讲解

    JVM(Java虚拟机)中进程工作目录讲解 每次我们用Java命令运行我们的Java程序,都会在JVM中开启一个进程,对于每一个进程,都会有一个相对应的工作目录,这个工作目录在虚拟机初始化的时候就已经 ...

  4. 【JVM】Java虚拟机

    目录 1. 内存区域(运行时数据区) 1.6 常量池 2. 垃圾收集 2.1 什么是垃圾 4. 对象 4.1 对象的创建 1. 内存区域(运行时数据区) 线程私有: 程序计数器.Java虚拟机栈.本地 ...

  5. JVM笔记 - Java 虚拟机关于 Synchronized 实现以及锁实现的总结

    本文是我在阅读 <深入理解Java虚拟机-第三版>和 极客时间 郑宇迪对于JVM的剖析后做的总结,如有不妥,不明白的地方,欢迎斧正 下面是一张比较经典的 Java 虚拟机锁实现流程图,不了 ...

  6. JVM笔记-java虚拟机

    JVM 常见问题 什么情况下会发生栈内存溢出 谈谈你对jvm的理解?Java8的虚拟机有什么更新? 什么是ooM?什么是stackoverflowerror? jvm的常用参数调优你知道哪些? 谈谈j ...

  7. java虚拟机是干吗的_从头开始学习-JVM(二):为什么java需要JVM(Java虚拟机)?...

    前言 在我们对java的越发了解之后,我们开始把注意力投到了java虚拟机这一块. 我们意识到,java所谓的"Write Once,Run Anywhere"的特性,就是基于JV ...

  8. 从头开始学习->JVM(二):为什么java需要JVM(Java虚拟机)?

    前言 在我们对java的越发了解之后,我们开始把注意力投到了java虚拟机这一块. 我们意识到,java所谓的"Write Once,Run Anywhere"的特性,就是基于JV ...

  9. JVM(java虚拟机)是什么,JVM作用和特征

    JVM简介 JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的. ...

  10. JVM(java虚拟机)

    一.了解JVM 1.什么是JVM JVM是Java Virtual Machine(Java虚拟机)的缩写,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟计算机功能来实现的,JVM屏蔽了与具体 ...

最新文章

  1. cnblogs和org2blog使用总结
  2. 《JavaScript高级程序设计》(第2版)上市
  3. Thinkphp 逻辑与,逻辑或的复合查询
  4. linux pcie驱动框架_Linux设备驱动框架设计
  5. 1001. A+B Format (20)---------------PAT开始
  6. 量子计算机完整的图片,记者带你走近世界首台超越早期经典计算机的光量子计算机(组图)...
  7. js操作indexedDB增删改查示例
  8. 《Effective Java》 第二讲:对于所有对象都通用的方法
  9. 还怕Web 安全编程学不会?来这里,准没错!
  10. webgis 行政图报错_开源WebGIS:地图发布与地图服务
  11. numpy_linspace函数
  12. 微信公众号实现人脸识别功能
  13. [Android6.0][MTK6737] 启动流程分析
  14. html控制台 打印 consol,浏览器console.log()打印输出台不显示输出内容……
  15. 《现代控制系统》第五章——反馈控制系统性能分析 5.3 二阶系统的性能
  16. python企业微信群聊_python3企业微信群组报警
  17. F5 LTM 常用oid列表
  18. 验证基于逻辑回归的隐马尔可夫模型的心音信号切分算法(literature study)
  19. java输出小数_java输出保留小数点
  20. Python–cookbook–1.数据结构与算法

热门文章

  1. android添加nfc门禁卡,IOS14nfc怎么添加门禁卡?NFC门禁卡教程[多图]
  2. 计算机没有显卡驱动,电脑没有显卡怎么办
  3. python计算单词长度_python – 返回字符串中的单词长度
  4. npm install 报错 gyp info it worked if it ends with ok
  5. 百度搜索框的测试点:
  6. OpenCV学习01-加载、修改、保存图像
  7. idea spring boot 修改 html,js 等不用重启即时生效
  8. 计算机作文 六年级,我和电脑600字_六年级作文_小学作文 - 265学校教育网
  9. 洛谷-P1007-魔法少女
  10. sql函数RIGHT的简单用法