java 虚拟机类加载机制
来源:互联网 发布:网络机顶盒看电视直播 编辑:程序博客网 时间:2024/05/02 01:30
类加载过程
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们开始的顺序如下图所示:
其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:
- 静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。
- 动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。
加载
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
1、通过一个类的全限定名来获取其定义的二进制字节流。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。
说到加载,不得不提到类加载器,下面就具体讲述下类加载器。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。
站在Java虚拟机的角度来讲,只存在两种不同的类加载器:
- 启动类加载器:它使用C++实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分。
- 所有其他的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:
- 启动类加载器:Bootstrap ClassLoader,跟上面相同。它负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
- 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
- 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
1)在执行非置信代码之前,自动验证数字签名。
2)动态地创建符合用户特定需要的定制化构建类。
3)从特定的场所取得java class,例如数据库中和网络中。
事实上当使用Applet的时候,就用到了特定的ClassLoader,因为这时需要从网络上加载java class,并且要检查相关的安全信息,应用服务器也大都使用了自定义的ClassLoader技术。
这几种类加载器的层次关系如下图所示:
这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在JDK1.2期间被引入并广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者们推荐给开发者的一种类的加载器实现方式。
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是Java类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要。例如,类java.lang.Object类存放在JDK\jre\lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了Object类在程序中的各种类加载器中都是同一个类。
验证
验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。- 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
- 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
- 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
- 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
假设一个类变量的定义为:
public static int value = 3;
那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
下表列出了Java中所有基本数据类型以及reference类型的默认零值:
这里还需要注意如下几点:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
- 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
假设上面的类变量value被定义为:
public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。回忆上一篇博文中对象被动引用的第2个例子,便是这种情况。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。
解析
class Super{ public static int m = 11; static{ System.out.println("执行了super类静态语句块"); } } class Father extends Super{ public static int m = 33; static{ System.out.println("执行了父类静态语句块"); } } class Child extends Father{ static{ System.out.println("执行了子类静态语句块"); } } public class StaticTest{ public static void main(String[] args){ System.out.println(Child.m); } }
执行了父类静态语句块
33
如果注释掉Father类中对m定义的那一行,则输出结果如下:
11
都匹配
System.out.println(Child.m);
^
1 错误
初始化
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。class Father{ public static int a = 1; static{ a = 2; } } class Child extends Father{ public static int b = a; } public class ClinitTest{ public static void main(String[] args){ System.out.println(Child.b); } }
总结
整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码(亦及字节码),但这里的执行代码只是个开端,它仅限于<clinit>()方法。类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。在前面介绍类加载器的代理模式的时候,提到过类加载器会首先代理给其它类加载器来尝试加载某个类。这就意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器,有可能不是同一个。真正完成类的加载工作是通过调用 defineClass来实现的;而启动类的加载过程是通过调用 loadClass来实现的。前者称为一个类的定义加载器(defining loader),后者称为初始加载器(initiating loader)。在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。方法 loadClass()抛出的是 java.lang.ClassNotFoundException异常;方法 defineClass()抛出的是 java.lang.NoClassDefFoundError异常。类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。
Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口
public class LinkTest { public static void main(String[] args) { ToBeLinked toBeLinked = null; System.out.println("Test link."); }}类 LinkTest引用了类ToBeLinked,但是并没有真正使用它,只是声明了一个变量,并没有创建该类的实例或是访问其中的静态域。在 Oracle的JDK 6中,如果把编译好的ToBeLinked的Java字节代码删除之后,再运行LinkTest,程序不会抛出错误。这是因为ToBeLinked类没有被真正用到,而Oracle的JDK 6所采用的链接策略使得ToBeLinked类不会被加载,因此也不会发现ToBeLinked的Java字节代码实际上是不存在的。如果把代码改成ToBeLinked toBeLinked = new ToBeLinked();之后,再按照相同的方法运行,就会抛出异常了。因为这个时候ToBeLinked这个类被真正使用到了,会需要加载这个类。
Java类和接口的初始化只有在特定的时机才会发生,这些时机包括:
MyClass obj = new MyClass()调用一个Java类中的静态方法。如
MyClass.sayHello()给Java类或接口中声明的静态域赋值。如
MyClass.value = 10访问Java类或接口中声明的静态域,并且该域不是常值变量。如
int value = MyClass.value在顶层Java类中执行assert语句。
通过Java反射API也可能造成类和接口的初始化。需要注意的是,当访问一个Java类或接口中的静态域的时候,只有真正声明这个域的类或接口才会被初始化。考虑下面的代码:
class B { static int value = 100; static { System.out.println("Class B is initialized."); //输出 }}class A extends B { static { System.out.println("Class A is initialized."); //不会输出 }}public class InitTest { public static void main(String[] args) { System.out.println(A.value); //输出100 }}在上述代码中,类InitTest通过A.value引用了类B中声明的静态域value。由于value是在类B中声明的,只有类B会被初始化,而类A则不会被初始化。
String path=System.getProperty("java.ext.dirs"); File dir=new File(path); if(!dir.exists()||!dir.isDirectory()){ return Collections.EMPTY_LIST; } File[] jars=dir.listFiles(); URL[] urls=new URL[jars.length]; for(int i=0;i<jars.length;i++){ urls[i]=sun.misc.URLClassPath.pathToURLs(jars[i].getAbsolutePath())[0]; } return Arrays.asList(urls);
方法二: try { URL[] extURLs = ((URLClassLoader)ClassLoader.getSystemClassLoader().getParent()).getURLs(); for (int i = 0; i < extURLs.length; i++) { System.out.println(extURLs[i]); } } catch (Exception e) {//…} 本机对应输出如下:file:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/dnsns.jarfile:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/localedata.jarfile:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/sunjce_provider.jarfile:/D:/DEMO/jdk1.5.0_09/jre/lib/ext/sunpkcs11.jar
因为ClassLoader中有一个classes成员变量就是用来保存类加载器加载的类列表,而且有一个方法void addClass(Class c) { classes.addElement(c);}这个方法被JVM调用。我们只要利用反射获得classes这个值就可以了,不过classes声明为private的,我们需要修改它的访问权限(没有安全管理器时很容易做到)classes = ClassLoader.class.getDeclaredField("classes");classes.setAccessible(true);List ret=(List) classes.get(cl); //classes是一个Vector
Java类加载器:http://blog.csdn.net/johnny901114/article/details/7738958
protected ClassLoader() { SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkCreateClassLoader(); } //默认将父类加载器设置为系统类加载器,getSystemClassLoader()获取系统类加载器 this.parent = getSystemClassLoader(); initialized = true; } protected ClassLoader(ClassLoader parent) { SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkCreateClassLoader(); } //强制设置父类加载器 this.parent = parent; initialized = true; }现在我们可能会有这样的疑问:扩展类加载器(ExtClassLoader)的父类加载器被强制设置为null了,那么扩展类加载器为什么还能将加载任务委派给启动类加载器呢?
标准扩展类加载器和系统类加载器及其父类(java.net.URLClassLoader和java.security.SecureClassLoader)都没有覆写java.lang.ClassLoader中默认的加载委派规则---loadClass(…)方法。有关java.lang.ClassLoader中默认的加载委派规则下面分析,如果父加载器为null,则会调用本地方法进行启动类加载尝试。
public Class<?> loadClass(String name)throws ClassNotFoundException { return loadClass(name, false); } protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 首先判断该类型是否已经被加载 Class c = findLoadedClass(name); if (c == null) { //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载 try { if (parent != null) { //如果存在父类加载器,就委派给父类加载器加载 c = parent.loadClass(name, false); } else { //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name) c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
Java 基础类在 Java 虚拟机启动后由 BL 一次性载入。构成 Java 应用程序的其它类在程序运行过程中由不同类装载器按需通过 loadClass() 方法装载。
//java.lang.Class.javapublicstatic Class<?> forName(String className)throws ClassNotFoundException { return forName0(className, true, ClassLoader.getCallerClassLoader());}//java.lang.ClassLoader.java// Returns the invoker's class loader, or null if none.static ClassLoader getCallerClassLoader() { // 获取调用类(caller)的类型 Class caller = Reflection.getCallerClass(3); // This can be null if the VM is requesting it if (caller == null) { returnnull; } // 调用java.lang.Class中本地方法获取加载该调用类(caller)的ClassLoader return caller.getClassLoader0();}//java.lang.Class.java//虚拟机本地实现,获取当前类的类加载器,前面介绍的Class的getClassLoader()也使用此方法native ClassLoader getClassLoader0();
//摘自java.lang.ClassLoader.javaprotected ClassLoader() { SecurityManager security = System.getSecurityManager();if (security != null) { security.checkCreateClassLoader();} this.parent = getSystemClassLoader(); initialized = true;}我们再来看一下对应的getSystemClassLoader()方法的实现:privatestaticsynchronizedvoid initSystemClassLoader() {//...sun.misc.Launcher l = sun.misc.Launcher.getLauncher();scl = l.getClassLoader();//...}我们可以写简单的测试代码来测试一下:System.out.println(sun.misc.Launcher.getLauncher().getClassLoader());本机对应输出如下:sun.misc.Launcher$AppClassLoader@197d257
即时用户自定义类加载器不指定父类加载器,那么,同样可以加载如下三个地方的类:
1. <Java_Runtime_Home>/lib下的类
2. < Java_Runtime_Home >/lib/ext下或者由系统变量java.ext.dir指定位置中的类
3. 当前工程类路径下或者由系统变量java.class.path指定位置中的类
类加载器在其应用场景的不同又可以分为如下类加载器:
1. 系统 ClassLoader
2. 调用者 ClassLoader
3. 线程上下文ClassLoader
这些类加载器主要是用于动态加载资源,也可以解决架包的重复问题.
调用者类加载器是指当前所在的类装载时所使用的ClassLoader,它可能是SystemClassLoader, 也可能是一个自定义的ClassLoader.可以通过getClass().getClassLoader()来得到Caller ClassLoader.例如,存在类A,是被AClassLoader所加载,A.class.getClassloader()为AClassLoader的实例,它就是A.class的Caller Classloader.
如果在A类中new一个B类,那么B类的类加载器就一定是AClassLoader吗。答案是错的。因为new一个对象,loadClass(B.class)可能在其父ClassLoader中就已经完成.
决定一个类的类加载器是defineClass,而判断两个类是否为同一对象的标准里面有一条是类加载器必须为相同.
现在有一个问题,如何使用指定的ClassLoader去完成类和资源的加载呢,或者说,当需要去实例化一个调用者ClassLoader和它的父ClassLoader都不能加载的类时,怎么办.
一个典型的一例子是Jaxp,当使用xerces的Sax实现时,我们首先需要通过rt.jar中的java.xml.parsers.SaxparserFactory.getinstance()得到xeceImpl.jar中的org.apache.xerces.jaxp.SAXParserFactory.Impl的实例,由于Jaxp的框架接口的类位于Java_hom/lib/rt.jar中,由bootStrap ClassLoader装载,处于ClassLoader层次结构中的最顶层,而xecesImpl.jar由低怪的ClassLoader装载,也就是说SaxParserFactoryImpl是在SaxParserFactory中实例化的,如前所述,使用SaxParserFactory的CallerClassLoader(boot)是完成不了这个任务的.这里我们需要理解下线程上下文ClassLoader.
线程上下文ClassLoader. 每一个线程都有一个关联的上下文ClassLoader.如果使用new Thread()方式生成新的线程,新线程将继承其父线程的上下文ClassLoader.如果程序对线程上下文ClassLoader没有任何改动的话,程序的所有线程将都使用System ClassLoader作为上下文ClassLoader.当使用Thread.currentThread().setContextClassLoader(classLoader)时,线程上下文ClassLoader就变成了指定的ClassLoader了。此时,在本线程的任意一处地方,调用Thread.currentThread().getContextClassLoader().都可以得到前面设置的ClassLoader.
一个线程来了,第一件事情便是设置ClassLoader来设置线程上下文类加载器,模块内部都使用线程上下文类加载器[比如某一接口处于AClassLoader,我设置AClassLoader为线程上下文类加载器,那么我通过getContextClassLoader就可以得到A类的类加载器,以后使用A类的类加载器,这样就可以统一调用了]
(有人可能会问了,我总不能每加载一个类,都使用上下文线程去加载吧,我笑了,其实这大可不必,只要你的类是你私有的[在当前类加载器父级加载器未加载过],就不需要重新加载)
例如:有时这种模式并不能总是奏效。这通常发生在JVM核心代码必须动态加载由应用程序动态提供的资源时。拿JNDI为例,它的核心是由JRE核心类(rt.jar)实现的。但这些核心JNDI类必须能加载由第三方厂商提供的JNDI实现。这种情况下调用父类加载器(原初类加载器)来加载只有其子类加载器可见的类,这种代理机制就会失效[双亲委托机制]。 <span style="font-family: Helvetica, Tahoma, Arial, sans-serif; font-size: 14px; line-height: 25px;">解决办法就是让核心JNDI类使用线程上下文类加载器,从而有效的打通类加载器层次结构,逆着代理机制的方向使用类加载器。</span>
- Java 虚拟机类加载机制
- Java虚拟机类加载机制
- Java 虚拟机 类加载机制
- java 虚拟机类加载机制
- Java 虚拟机类加载机制
- Java虚拟机类加载机制
- java虚拟机类加载机制
- Java虚拟机类加载机制
- Java虚拟机类加载机制
- Java虚拟机类加载机制
- java虚拟机类加载机制
- Java虚拟机类加载机制
- Java虚拟机类加载机制
- Java虚拟机类加载机制
- java虚拟机类加载机制
- Java虚拟机类加载机制
- java虚拟机类加载机制
- Java虚拟机--类加载机制
- 扩展vbox的虚拟磁盘(.vdi)
- nyoj-1027-阵地防守
- android源码下载mac
- poj 1112 Team Them Up!(建图+dp)
- IO流(流操作规律-1)-(流操作规律-2)
- java 虚拟机类加载机制
- 我的伤 你不懂
- iOS_14_tableViewController_xib创建和封装自定义cell
- Linux下获取配置文件信息
- log4j输出多个自定义日志文件,动态配置路径
- IO流(异常的日志信息)
- 继续畅通工程(prim——最小生成树)
- eclipse中的快捷键
- jenkins-01介绍说明