一、什么是类加载器?
在上篇《JVM类加载机制详解——类加载过程》,介绍了类的加载过程,在类的加载过程中的第一个阶段是“加载”(Loading)阶段,“加载”的一个很重要的前提便是通过一个类的全限定名来获取该类的字节码,而这个获取类字节码的动作就是类加载器所要干的事。
二、类加载器种类
Java虚拟机中有3种类加载器,它们分别是启动类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)和应用类加载器(Application Class Loader),另外,JVM还支持自定义类加载器(User Class Loader),所以准确地说有4种类加载器。这些类加载器的逻辑关系用一张图来描述的话,类似下图:
各种类加载器具有父子层级关系,看似跟Java中父子继承关系类似,实际却完全不同,这里的“父子关系”使用的是组合关系来复用父加载器,我个人理解只是JDK开发者为了实现双亲委派的类加载架构而定义的逻辑上的关系。
2.1 各种类加载器的加载路径
- 启动类加载器:默认加载<JAVA_HOME>/jre/lib/*.jar,启动类加载器所加载的jar包名字在JVM中都是写死的,所以随便放一个jar到这个目录下,是不会被加载的;除此之外,还可以通过-Xbootclasspath参数跟上路径来改变BootstrapClassLoader的加载目录。启动类加载器无法被Java程序直接调用,在Java中,它是null,这个也很容易来证明,随便写段代码来验证一下:
public static void main(String[] args) {
System.out.println(String.class.getClassLoader());
}
运行结果是null,因为java.lang.String在rt.jar中,由BootstrapClassLoader加载。
String的ClassLoader:null
- 扩展类加载器:默认加载<JAVA_HOME>/jre/lib/ext/*.jar或者被java.ext.dirs系统变量所指定的路径中所有的类库。
- 应用类加载器:负责加载CLASS_PATH目录下的所有类库,如果应用程序中没有自定义类加载器,那么当前类默认就是使用应用类加载器加载。
2.2 三种类加载器是如何创建的
在OpenJdk源码中,启动类加载器是由C++代码编写,它的作用主要就是为了加载sun/launcher/LauncherHelper类,然后执行这个类里面的checkAndLoadMain方法,而这个方法主要干了这几件事:
- 找到main方法,加载main方法所在的类;
- 启动扩展类加载器;
- 启动应用类加载器;
我们看下代码,从启动类加载器开始看,首先在openJdk源码中的openjdk\jdk\src\share\bin\java.c文件中,JavaMain方法(篇幅有限,只贴了关键代码,下同),这个方法里会加载main方法所在类:
JavaMain(void * _args)
{
……
//加载main方法所在类
mainClass = LoadMainClass(env, mode, what);
……
}
//继续跟进LoadMainClass方法
/*
* Loads a class and verifies that the main class is present and it is ok to
* call it for more details refer to the java implementation.
*/
static jclass
LoadMainClass(JNIEnv *env, int mode, char *name)
{
jmethodID mid;
jstring str;
jobject result;
jlong start, end;
//跟进这个方法可以知道,主要是通过启动类加载器来加载加载sun/launcher/LauncherHelper类
jclass cls = GetLauncherHelperClass(env);
NULL_CHECK0(cls);
if (JLI_IsTraceLauncher()) {
start = CounterGet();
}
//主要是为了执行sun/launcher/LauncherHelper.checkAndLoadMain(boolean,int,String)方法
NULL_CHECK0(mid = (*env)->(env, cls,
"checkAndLoadMain",
"(ZILjava/lang/String;)Ljava/lang/Class;"));
str = NewPlatformString(env, name);
CHECK_JNI_RETURN_0(
result = (*env)->CallStaticObjectMethod(
env, cls, mid, USE_STDERR, mode, str));
if (JLI_IsTraceLauncher()) {
end = CounterGet();
printf("%ld micro seconds to load main class\n",
(long)(jint)Counter2Micros(end-start));
printf("----%s----\n", JLDEBUG_ENV_ENTRY);
}
return (jclass)result;
}
以上代码段就是启动类加载器的启动过程,也说明了启动类加载器并不是一个具体的实体,而是这段代码逻辑就是启动类加载器,此时已经将sun.launcher.LauncherHelper类加载进JVM,接着就会去执行这个类里面的checkAndLoadMain方法,所以接下来看下sun.launcher.LauncherHelper类代码,这是我们熟悉的Java代码。
public enum LauncherHelper {
//……省略
private static final ClassLoader scloader = ClassLoader.getSystemClassLoader();
//……省略
/**
* This method does the following:
* 1. gets the classname from a Jar's manifest, if necessary
* 2. loads the class using the System ClassLoader
* 3. ensures the availability and accessibility of the main method,
* using signatureDiagnostic method.
* a. does the class exist
* b. is there a main
* c. is the main public
* d. is the main static
* e. does the main take a String array for args
* 4. if no main method and if the class extends FX Application, then call
* on FXHelper to determine the main class to launch
* 5. and off we go......
*
* @param printToStderr if set, all output will be routed to stderr
* @param mode LaunchMode as determined by the arguments passed on the
* command line
* @param what either the jar file to launch or the main class when using
* LM_CLASS mode
* @return the application's main class
*/
public static Class<?> checkAndLoadMain(boolean printToStderr,
int mode,
String what) {
initOutput(printToStderr);
// get the class name
String cn = null;
switch (mode) {
//从Class文件中获取主类
case LM_CLASS:
cn = what;
break;
//从jar包中获取主类
case LM_JAR:
cn = getMainClassFromJar(what);
break;
default:
// should never happen
throw new InternalError("" + mode + ": Unknown launch mode");
}
Class<?> mainClass = null;
//使用scloader类加载器加载主类
mainClass = scloader.loadClass(cn);
return mainClass;
}
//scloader就是通过ClassLoader.getSystemClassLoader()方法获取,
//根据方法名可知这是系统类加载器,也就是使用系统类加载器来加载main方法所在类
//跟进getSystemClassLoader()方法
@CallerSensitive
public static ClassLoader getSystemClassLoader() {
initSystemClassLoader();
if (scl == null) {
return null;
}
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
checkClassLoaderPermission(scl, Reflection.getCallerClass());
}
return scl;
}
private static synchronized void initSystemClassLoader() {
if (!sclSet) {
if (scl != null)
throw new IllegalStateException("recursive invocation");
//初始化sun.misc.Launcher类,为了得到Launcher类中的类加载器
sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
if (l != null) {
Throwable oops = null;
//获取类加载器,由下面的代码可知:此加载器即系统类加载器,也就是应用类加载器
scl = l.getClassLoader();
try {
scl = AccessController.doPrivileged(
new SystemClassLoaderAction(scl));
} catch (PrivilegedActionException pae) {
oops = pae.getCause();
if (oops instanceof InvocationTargetException) {
oops = oops.getCause();
}
}
if (oops != null) {
if (oops instanceof Error) {
throw (Error) oops;
} else {
// wrap the exception
throw new Error(oops);
}
}
}
sclSet = true;
}
}
终于到了我们想要看到的代码sun.misc.Launcher,它是Java虚拟机启动后的入口类,或者说它是由C++程序(其实主要就是BootstrapClassLoader)切入到Java程序的入口类,上面说的sun.launcher.LauncherHelper类也是为了引出这个类,看下这个类的源码:
/**
* This class is used by the system to launch the main application.
Launcher */
public class Launcher {
private static URLStreamHandlerFactory factory = new Factory();
private static Launcher launcher = new Launcher();
private static String bootClassPath =
System.getProperty("sun.boot.class.path");
public static Launcher getLauncher() {
return launcher;
}
private ClassLoader loader;
public Launcher() {
// Create the extension class loader
ClassLoader extcl;
try {
//获取扩展类加载器
extcl = ExtClassLoader.getExtClassLoader();
} catch (IOException e) {
throw new InternalError(
"Could not create extension class loader", e);
}
// Now create the class loader to use to launch the application
try {
//获取应用类加载器,并将extcl即扩展类加载器作为父类加载器传参
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader", e);
}
// 设置线程上下文类加载器,为了给SPI使用
Thread.currentThread().setContextClassLoader(loader);
// Finally, install a security manager if requested
String s = System.getProperty("java.security.manager");
if (s != null) {
SecurityManager sm = null;
if ("".equals(s) || "default".equals(s)) {
sm = new java.lang.SecurityManager();
} else {
try {
sm = (SecurityManager)loader.loadClass(s).newInstance();
} catch (IllegalAccessException e) {
} catch (InstantiationException e) {
} catch (ClassNotFoundException e) {
} catch (ClassCastException e) {
}
}
if (sm != null) {
System.setSecurityManager(sm);
} else {
throw new InternalError(
"Could not create SecurityManager: " + s);
}
}
}
}
从Launcher类的注释也可以看到:系统使用这个类来启动主应用程序。在它的构造方法里,分别有几句关键代码:
- extcl = ExtClassLoader.getExtClassLoader(); // 创建扩展类加载器
- loader = AppClassLoader.getAppClassLoader(extcl); // 创建应用类类加载器,并将扩展类加载器作为父类加载器
- Thread.currentThread().setContextClassLoader(loader); // 设置线程上下文类加载器,供SPI使用,由上面代码可知,线程上下文类加载器默认是AppClassLoader。另外,如果线程创建时,没有设置线程上下文类加载器,那么将会从父线程中继承一个。
以上代码看着可能有些眼花缭乱,其实逻辑很简单,总结下来,就是依次启动三类加载器:启动类加载器、扩展类加载器、应用类加载器,最后由应用类加载器来加载当前类,然后遵循双亲委派模型,依次委派父类加载器进行加载。主要步骤如下:
- 由C++代码(启动类加载器)加载sun/launcher/LauncherHelper类,然后执行这个类里面的checkAndLoadMain方法;
- 其实说白了,启动类加载器启动之后,首先会去加载主类,怎么去找到这个主类呢?那就是sun/launcher/LauncherHelper这个类要干的事,所以启动类加载器会去加载这个类,而在其checkAndLoadMain方法中,会去加载main方法所在的类;
- 既然要加载main类,那么由谁来加载呢?在checkAndLoadMain方法中已经明确说了:由系统类加载器来负责加载,可能你又要问:啥是系统类加载器?我个人理解因为它是ClassLoader类中的getSystemClassLoader()方法得到的类加载器,顾名思义,这个类也被称为“系统类加载器”,而在getSystemClassLoader()方法中会去创建一个Launcher对象,获取系统类加载器的主要逻辑就在Launcher的构造方法里;
- 在Launcher()方法里,才有了上面所说的几句关键代码,最终就是由 AppClassLoader.getAppClassLoader(extcl)方法所得到的加载器(应用类加载器)来加载main方法所在类。
三 双亲委派模型
什么是双亲委派?其实在上文中已经提到了双亲委派,在第二节介绍类加载器种类时,贴了一张图,那张图所表示的各个类加载器之间的层次关系,就称为双亲委派模型。
双亲委派的过程是这样的:如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是首先会去自己的内存空间中查找是否已经加载过这个类,如果没有,就把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时, 子加载器才会尝试自己去完成加载。
3.1 双亲委派的内存模型
上面对双亲委派的过程的描述中,可能有人注意到这句话:首先会去自己的内存空间中查找是否已经加载过这个类,这是什么意思呢?我们都知道,JVM加载一个类之后,会把这个类的元信息存储中方法区中,而在方法区中,又根据不同的类加载器划分了一个个独立的空间,每一个类加载器加载一个类之后都会放入自己的这个独立空间中,下次再次加载这个类时,就首先去自己的空间中查找,如果有了,就不再加载了,直接返回。因此,同一个类经过不同的类加载器加载之后,它们反而是不同的类,就是因为它们存储在不同的内存空间中。由于这个特性,可能会影响equals()方法、instanceof关键字等诸如类似的比较两个类是否相等的操作的结果,用一段代码来演示这个现象(代码示例摘抄自《深入理解Java虚拟机第3版》)
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
//自定义一个类加载器
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1)+".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("com.jvmTest.classloader.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.jvmTest.classloader.ClassLoaderTest);
}
}
运行结果:
class com.jvmTest.classloader.ClassLoaderTest
false
两行输出结果中, 从第一行可以看到这个对象确实是类com.jvmTest.classloader.ClassLoaderTest实例化出来的, 但在第二行的输出中却发现这个对象与类com.jvmTest.classloader.ClassLoaderTest做所属类型检查的时候返回了false。 这是因为Java虚拟机中同时存在了两个ClassLoaderTest类, 一个是由虚拟机的应用程序类加载器所加载的, 另外一个是由我们自定义的类加载器加载的, 虽然它们都来自同一个Class文件, 但在Java虚拟机中仍然是两个互相独立的类, 做对象所属类型检查时的结果自然为false。
3.2 双亲委派的作用
双亲委派一个重要的作用是保证了Java程序的安全和稳定。例如java.lang包下面的类提供了Java程序运行所必须的类库,如java.lang.Object,它是所有类的父类(或者java.lang.String类,在程序中被大量使用到),不管你是使用哪个类加载器去加载,这些类都必须要被加载,而它放在rt.jar包之中,前面在介绍各个类加载器的加载路径的时候已经介绍过,rt.jar包是由启动类加载器加载的,正是有了双亲委派,才保证了下面所有的类加载器都无法加载到这个包,只能委派给启动类加载器来加载,这样就保证了在整个方法区中,只有唯一一个java.lang.Object类。否则,如果用户自己也写了一个java.lang.Object类,那么就会被应用类加载器加载并放入其内存空间中,再加上系统类加载器加载的rt.jar中的java.lang.Object,这样系统中就存在多个不同的Object类,我该如何使用呢?问题就会显而易见。所以双亲委派机制有效防止了用户篡改Java中的类库,保证了Java程序运行的稳定性。
双亲委派还为开发者提供了扩展性,比如有一个特殊需求,程序中需要实现一个核心功能,该功能代码列为机密,为了保护该功能源码,防止被反编译,需要对编译之后的class文件进行加密,那么加载这个class之前需要进行动态解密,这时候就需要自定义类加载器来加载这个类,而其它加载器无法加载。
双亲委派模型主要是由java.lang.ClassLoader.loadClass()方法实现的,它的代码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 首先, 检查请求的类是否已经被加载过了(类加载器的存储空间)
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 如果父类加载器不为空,则用父类加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 如果父类加载器为空(根据上面所说,即为BootstrapClassLoader),则默认使用启动类加载器作为父加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
// 如果父类加载器加载失败,则抛出ClassNotFoundException 异常
}
// 如果抛出ClassNotFoundException 异常,并且还没有被加载到,则调用自己的findClass()方法加载
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//解析class:符合引用转直接引用,在我上篇文章里有介绍:《JVM类加载机制——类加载的过程》
resolveClass(c);
}
return c;
}
}
3.3 打破双亲委派
既然双亲委派保证了Java程序的安全和稳定,还为我们提供了扩展性,为什么还要打破它呢?这是因为双亲委派的局限性,并不是在所有场景下都适用,我们有时候不想要委派父类加载器进行加载,就要当前加载器加载,这时候就需要打破双亲委派。
打破双亲委派有两个思路:
1)由父类加载器去请求子类加载器进行类的加载
2)由当前类加载器直接加载,不向上进行委派
对于第1个思路,有一个很经典的例子:JDBC的驱动java.sql.Driver接口,它是所有数据库厂商必须要实现的规范,应用程序需要通过java.sql.DriverManager(驱动管理类)来调用这些实现类的API,而java.sql.Driver和java.sql.DriverManager都在rt.jar包中,由上文可知,它们都是由启BootstrapClassLoader所加载,那么DriverManager要想调用各个数据库厂商实现的API的话(SPI机制),也必须由BootstrapClassLoader来加载这些实现类才行,但是数据库厂商的实现都在应用程序中,都是由AppClassLoader来加载,这样岂不是矛盾了吗?为了解决这个问题,JDK中引入了线程上下文类加载器(Thread Context ClassLoader),如上文所说,在三种类加载器启动并建立上下层级关系的时候,会设置线程上下文类加载器(默认为AppClassLoader),也就是说DriverManager使用上下文类加载器去加载数据库厂商提供的驱动程序,这时加载模型就变成了BootStrapClassLoader -> AppClassLoader,这与双亲委派机制完全相反了,所以这种机制已经打破了双亲委派,这也是SPI机制最常见的加载方式。DriverManager中关于利用SPI机制来打破双亲委派的代码如下:
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// If the driver is packaged as a Service Provider, load it.
// Get all the drivers through the classloader
// exposed as a java.sql.Driver.class service.
// ServiceLoader.load() replaces the sun.misc.Providers()
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//采用ServiceLoader来加载Driver的实现类
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
/* Load these drivers, so that they can be instantiated.
* It may be the case that the driver class may not be there
* i.e. there may be a packaged driver with the service class
* as implementation of java.sql.Driver but the actual class
* may be missing. In that case a java.util.ServiceConfigurationError
* will be thrown at runtime by the VM trying to locate
* and load the service.
*
* Adding a try catch block to catch those runtime errors
* if driver not available in classpath but it's
* packaged as service and that service is there in classpath.
*/
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
延伸扩展:
当SPI的服务提供者多于一个的时候, 代码就只能根据具体提供者的类型来硬编码判断, 为了消除这种极不优雅的实现方式, 在JDK 6时, JDK提供了java.util.ServiceLoader类, 以META-INF/services中的配置信息, 辅以责任链模式, 这才算是给SPI的加载提供了一种相对合理的解决方案。
如果我不想让父加载器来加载我指定的类,就是想用自己定义的一个加载器来加载,有什么办法呢?这就是上面的第2个思路,我写了一段代码用于说明这个问题:
/**
* @Description 自定义类加载器,打破双亲委派
* @Param
* @return
**/
public class ClassLoaderTest extends ClassLoader {
/**
* @Description 重写loadClass方法,用于打破双亲委派:只有com.jvmTest.classloader.User使用自定义加载器加载
* @Param [name]
* @return java.lang.Class<?>
**/
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> c;
if(name.equals("com.jvmTest.classloader.User")){
c = findClass(name);
}else{
c = getParent().loadClass(name);
}
return c;
}
/**
* @Description findClass,用于使用自定义加载器加载类
* @Param [name]
* @return java.lang.Class<?>
**/
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
byte[] data = readClassBytes(className.replace('.', '/'));
return defineClass(className, data, 0, data.length);
}
//读取Class文件到byte[]
private byte[] readClassBytes(String name) throws ClassNotFoundException {
InputStream inputStream = null;
ByteArrayOutputStream outputStream = null;
String rootPath = this.getClass().getResource("/").getPath();
File file = new File(rootPath + name + ".class");
if (!file.exists()) {
throw new ClassNotFoundException(name);
}
try {
inputStream = new FileInputStream(file);
outputStream = new ByteArrayOutputStream();
int size = 0;
byte[] buffer = new byte[1024];
while ((size = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, size);
}
return outputStream.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
inputStream.close();
outputStream.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
return null;
}
public static void main(String[] args) throws ClassNotFoundException {
ClassLoaderTest classLoaderTest2 = new ClassLoaderTest();
Class clazz = classLoaderTest2.loadClass(User.class.getName());
System.out.println(clazz);
System.out.println(clazz.getClassLoader());
}
}
上述代码主要演示了自定义加载器来打破双亲委派,代码写的比较简陋,但是足以说明这个例子,上面说过,双亲委派的实现就是在ClassLoader.loadClass()方法中,所以要想打破双亲委派,就必须重写这个方法,我这里定义了一个com.jvmTest.classloader.User类,并且只有这个类才使用自定义加载器加载,而findClass方法主要是为了将com.jvmTest.classloader.User.class文件读入byte[],并动态生成Class对象(defineClass方法),所以输出结果为:
class com.jvmTest.classloader.User
com.jvmTest.classloader.ClassLoaderTest@1b6d3586
第一行结果说明重写的findClass方法成功地将User来的字节码生成了User的Class对象,第二行结果证明了User类确实使用了我自定义的加载器ClassLoaderTest来加载。
延伸扩展:
通过上述代码,可能有人会有一个疑问:为什么要重写两个方法:loadClass和findClass,它们什么区别?loadClass主要是双亲委派的实现逻辑,如果用户需要打破双亲委派,就必须重写它的逻辑;如果只是想自定义一个类加载器,但并不想打破双亲委派,则只需重写findClass方法即可,而findClass是jdk1.2中引入的,在jdk中它没有具体逻辑,直接就是抛出ClassNotFoundException异常(所以如果上面代码不重写,就会抛异常),所以它就是为了给开发者来重写的,关于这两个方法我会在下一遍文章中进行详细介绍
四 总结:
关于JVM的类加载机制,我用了两篇文章类进行介绍,另一篇篇《JVM类加载机制——类加载的过程》,上篇所介绍的类加载过程以及本篇的类加载器和双亲委派机制,就是我们常说的JVM的类加载器子系统。
对于文中遗留的问题:对java.lang.ClassLoader类中的loadClass(name)方法和findClass(name)方法的疑惑,请点这里:《JVM类加载器解惑——loadClass(name)和findClass方法》,之所以单独拎出来,主要是因为我当初就是为这两个方法迷糊了很久才弄明白,所以只是为了做个笔记记录一下自己的理解,已经理解的同学可以忽略。