Tomcat源码分析 -- Tomcat类加载器

本章结构如下:

  • 前言
  • Java类加载机制
  • tomcat类加载器
  • tomcat类加载器源码分析

一、前言

下载tomcat解压后,可以在webapps目录下看到几个文件夹(这些都是web应用),webapps对应到tomcat容器中的Host,里面的文件夹则对应到Context。tomcat启动后,webapps下的所有web应用都可以提供服务。

那么就有一个问题,假如webapps下有两个应用app1和app2,它们有各自独立依赖的jar包,又有共同依赖的jar包,这些相同的jar包有些版本相同,有些又不相同,这种情况下,tomcat是如何加载这些jar包的呢?

带着这个疑问,一步步来分析tomcat的类加载机制吧。

二、Java类加载机制

在这之前,当然要先了解一下java中类加载时怎样的,毕竟tomcat是用java写的,它的加载机制也是基于java的类加载机制。

2.1、类加载器

1.什么是类加载器?

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

2.如何判断两个类是否相等?

类加载器用于实现类的加载动作。对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在Java虚拟机中的唯一性,每一个类,都拥有一个独立的类名称空间。也就是说:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

2.2、双亲委派模型

Java 提供三种类型的系统类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):由C++语言实现,属于JVM的一部分,其作用是加载 <JAVA_HOME>\lib 目录中的文件,或者被-Xbootclasspath参数所指定的路径中的文件,并且该类加载器只加载特定名称的文件(如 rt.jar),而不是该目录下所有的文件。启动类加载器无法被Java程序直接引用。
  2. 扩展类加载器(Extension ClassLoader):由sun.misc.Launcher.ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader):也称系统类加载器,由sun.misc.Launcher.AppClassLoader实现。负责加载用户类路径(Class Path)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。加载流程如下图所示:

用一段代码测试一下:

 public static void main(String[] args) {
        ClassLoader loader = Xxx.class.getClassLoader();
        while (loader!=null){
            System.out.println(loader);
            loader = loader.getParent();
        }
    }

结果:

从结果我们可以看出,默认情况下,用户自定义的类使用 AppClassLoader 加载,AppClassLoader 的父加载器为 ExtClassLoader,但是 ExtClassLoader 的父加载器却显示为空,这是什么原因呢?究其缘由,启动类加载器属于 JVM 的一部分,它不是由 Java 语言实现的,在 Java 中无法直接引用,所以才返回空。

java这种类加载层级称为双亲委派模型。它的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

为什么要这样呢?

都知道java.lang.Object是java中所有类的父类,它存放在rt.jar之中,按照双亲委派模型,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。试想,如果没有使用双亲委派模型,由各个类加载器自行去加载,显然,这就存在很大风险,用户完全可以恶意编写一个java.lang.Object类,然后放到ClassPath下,那系统就会出现多个Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

2.3、简单看下源码

protected Class<?> loadClass(String var1, boolean var2) throws ClassNotFoundException {
    synchronized(this.getClassLoadingLock(var1)) {
        //首先, 检查请求的类是否已经被加载过了  
        Class var4 = this.findLoadedClass(var1);
        if(var4 == null) {
            long var5 = System.nanoTime();

            try {
                if(this.parent != null) {
                    var4 = this.parent.loadClass(var1, false);
                } else {
                    var4 = this.findBootstrapClassOrNull(var1);
                }
            } catch (ClassNotFoundException var10) {
                //如果父类加载器抛出ClassNotFoundException 
                //说明父类加载器无法完成加载请求  
                ;
            }

            if(var4 == null) {
                long var7 = System.nanoTime();
                //在父类加载器无法加载的时候  
                //再调用本身的findClass方法来进行类加载  
                var4 = this.findClass(var1);
                PerfCounter.getParentDelegationTime().addTime(var7 - var5);
                PerfCounter.getFindClassTime().addElapsedTimeFrom(var7);
                PerfCounter.getFindClasses().increment();
            }
        }

        if(var2) {
            this.resolveClass(var4);
        }

        return var4;
    }
}

从源码可以看出,ExtClassLoader 和 AppClassLoader都继承自 ClassLoader 类,ClassLoader 类中通过 loadClass 方法来实现双亲委派机制。整个类的加载过程可分为如下三步:

  1. 查找对应的类是否已经加载。
  2. 若未加载,则判断当前类加载器的父加载器是否为空,不为空则委托给父类去加载,否则调用启动类加载器加载(findBootstrapClassOrNull 再往下会调用一个 native 方法)。
  3. 若第二步加载失败,说明父类加载器无法完成加载请求 ,则调用当前类加载器加载。

详细可以参考这篇博文:

Java 类加载机制详解

2.4、打破双亲委派模型(来自《深入理解Java虚拟机:JVM高级特性与最佳实践》)

双亲委派模型并不是一个强制性的约束模型,而是Java设计者推荐给开发者的类加载器实现方式。

它很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?

这并非是不可能的事情,一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?

Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。

有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则。

还有“被破坏”是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。

当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
1)将以java.*开头的类委派给父类加载器加载。
2)否则,将委派列表名单内的类委派给父类加载器加载。
3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的Class Path,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类查找失败。

三、tomcat类加载器

了解了java的双亲委派模型,现在回到正题上,tomcat的类加载器是怎么样的?

3.1、Web容器应该具备的特性

不难想象,一个功能健全的Web容器,它的类加载器必然有多个,因为它应该具备如下特性:

  • 隔离性:部署在同一个Web容器上的两个Web应用程序所使用的Java类库可以实现相互隔离。设想一下,两个Web应用,一个使用了Spring2.5,另一个使用了教新的4.0,应用服务器使用一个类加载器,Web应用将会因为jar包覆盖而无法启动。
  • 灵活性:Web应用之间的类加载器相互独立,那么就能针对一个Web应用进行重新部署,此时Web应用的类加载器会被重建,而且不会影响其他的Web应用。如果采用一个类加载器,类之间的依赖是杂乱复杂的,无法完全移出某个应用的类。
  • 性能:部署在同一个Web容器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求也很常见,例如,用户可能有10个使用Spring组织的应用程序部署在同一台服务器上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到Web容器的内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
  • ...

3.2、tomcat类加载器结构

了解了一款Web容器应该具备的特性,明白了Web容器的类加载器有多个,再来看tomcat的类加载器结构。

首先上张图,整体看下tomcat的类加载器:

可以看到在原先的java类加载器基础上,tomcat新增了几个类加载器,包括3个基础类加载器和每个Web应用的类加载器,其中3个基础类加载器可在conf/catalina.properties中配置,具体介绍下:

  • Common:以应用类加载器为父类,是tomcat顶层的公用类加载器,其路径由conf/catalina.properties中的common.loader指定,默认指向${catalina.home}/lib下的包。
  • Catalina:以Common类加载器为父类,是用于加载Tomcat应用服务器的类加载器,其路径由server.loader指定,默认为空,此时tomcat使用Common类加载器加载应用服务器。
  • Shared:以Common类加载器为父类,是所有Web应用的父类加载器,其路径由shared.loader指定,默认为空,此时tomcat使用Common类加载器作为Web应用的父加载器。
  • Web应用:以Shared类加载器为父类,加载/WEB-INF/classes目录下的未压缩的Class和资源文件以及/WEB-INF/lib目录下的jar包,该类加载器只对当前Web应用可见,对其他Web应用均不可见。

默认情况下,Common、Catalina、Shared类加载器是同一个,但可以配置3个不同的类加载器,使他们各司其职。

首先,Common类加载器复杂加载Tomcat应用服务器内部和Web应用均可见的类,如Servlet规范相关包和一些通用工具包。

其次,Catalina类加载器负责只有Tomcat应用服务器内部可见的类,这些类对Web应用不可见。比如,想实现自己的会话存储方案,而且该方案依赖了一些第三方包,当然是不希望这些包对Web应用可见,这时可以配置server.load,创建独立的Catalina类加载器。

再次,Shared类复杂加载Web应用共享类,这些类tomcat服务器不会依赖。

相信看到这,引言中的疑问已经解开了吧。

那还有一个问题,如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的,那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢?

如果按主流的双亲委派机制,显然无法做到让父类加载器加载的类去访问子类加载器加载的类,但使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。spring加载类所用的Classloader是通过Thread.currentThread().getContextClassLoader()来获取的,而当线程创建时会默认setContextClassLoader(AppClassLoader),即线程上下文类加载器被设置为AppClassLoader,spring中始终可以获取到这个AppClassLoader(在Tomcat里就是WebAppClassLoader)子类加载器来加载bean,以后任何一个线程都可以通过getContextClassLoader()获取到WebAppClassLoader来getbean了。

接下来,看一看源码。

四、tomcat类加载器源码分析

1.Common/Catalina/Shared ClassLoader的创建

首先先看下tomcat的类加载器继承结构:

相信看到这会很疑惑,这和上面介绍的tomcat类加载器结构不一样啊。

是这样的,双亲委派模型本不是通过继承实现的,而是组合,所以AppClassLoader没有继承自ExtClassLoader,WebappClassLoader也没有继承自AppClassLoader。至于Common ClassLoader,Shared ClassLoader,Catalina ClassLoader则是在启动时初始化的三个不同名字的URLClassLoader。

来到BootStrap中看一下:

public static void main(String args[]) {
    if (daemon == null) {
        // Don't set daemon until init() has completed
        Bootstrap bootstrap = new Bootstrap();
        try {
            bootstrap.init();
        } catch (Throwable t) {
            handleThrowable(t);
            t.printStackTrace();
            return;
        }
        daemon = bootstrap;
    } else {
        // When running as a service the call to stop will be on a new
        // thread so make sure the correct class loader is used to prevent
        // a range of class not found exceptions.
        Thread.currentThread().setContextClassLoader(daemon.catalinaLoader);
    }
    
    ...
}

先判断Bootstrap是否为null,不为null,直接将Catalina ClassLoader设置到当前线程,用于加载服务器相关类,为null则进入bootstrap的init方法。

public void init() throws Exception {

    initClassLoaders();

    Thread.currentThread().setContextClassLoader(catalinaLoader);

    SecurityClassLoad.securityClassLoad(catalinaLoader);
    
    ...
}

init方法会调用initClassLoaders,同样也会将Catalina ClassLoader设置到当前线程设置到当前线程,进入initClassLoaders来看看。

private void initClassLoaders() {
    try {
        commonLoader = createClassLoader("common", null);
        if( commonLoader == null ) {
            // no config file, default to this loader - we might be in a 'single' env.
            commonLoader=this.getClass().getClassLoader();
        }
        catalinaLoader = createClassLoader("server", commonLoader);
        sharedLoader = createClassLoader("shared", commonLoader);
    } catch (Throwable t) {
        handleThrowable(t);
        log.error("Class loader creation threw exception", t);
        System.exit(1);
    }
}

应该就很明白了,会创建三个ClassLoader,CommClassLoader,Catalina ClassLoader,SharedClassLoader,正好对应前面介绍的三个基础类加载器。

再进入createClassLoader可以看到这三个基础类加载器所加载的资源刚好对应conf/catalina.properties中的common.loader,server.loader,shared.loader:

private ClassLoader createClassLoader(String name, ClassLoader parent)
        throws Exception {

    String value = CatalinaProperties.getProperty(name + ".loader");
    if ((value == null) || (value.equals("")))
        return parent;

    value = replace(value);

    List<Repository> repositories = new ArrayList<>();

    String[] repositoryPaths = getPaths(value);

    for (String repository : repositoryPaths) {
        // Check for a JAR URL repository
        try {
            @SuppressWarnings("unused")
            URL url = new URL(repository);
            repositories.add(
                    new Repository(repository, RepositoryType.URL));
            continue;
        } catch (MalformedURLException e) {
            // Ignore
        }

        // Local repository
        if (repository.endsWith("*.jar")) {
            repository = repository.substring
                (0, repository.length() - "*.jar".length());
            repositories.add(
                    new Repository(repository, RepositoryType.GLOB));
        } else if (repository.endsWith(".jar")) {
            repositories.add(
                    new Repository(repository, RepositoryType.JAR));
        } else {
            repositories.add(
                    new Repository(repository, RepositoryType.DIR));
        }
    }

    return ClassLoaderFactory.createClassLoader(repositories, parent);
}

2.Common/Catalina/Shared ClassLoader的层次构建

Common/Catalina/Shared ClassLoader的创建好了,肯定是要被使用的,是在哪里使用的呢?它们之间同Webapp ClassLoader又是怎么联系起来的?

既然sharedClassLoader被传入到Catalina中,就来看它的getParentClassLoader调用栈。

经过层层调用,来带StandardContext的startInternal方法,这个方法很长很复杂,就不全贴出来,里面有这样一段:

 if (getLoader() == null) {
    WebappLoader webappLoader = new WebappLoader(getParentClassLoader());
    webappLoader.setDelegate(getDelegate());
    setLoader(webappLoader);
}

它会创建WebappLoader对象,并通过setLoader(webappLoader)赋值到一个实例变量中,然后会调用WebappLoader的start方法:

...
if (ok) {
    // Start our subordinate components, if any
    Loader loader = getLoader();
    if (loader instanceof Lifecycle) {
        ((Lifecycle) loader).start();
    }
    ...
}
...

这里关系到tomcat的生命周期机制,先不纠结,直接找到start方法,start方法是在父类中,最后要调回到WebappLoader中的startInternal方法。

该方法中有这样一段:

...
classLoader = createClassLoader();
classLoader.setResources(context.getResources());
classLoader.setDelegate(this.delegate);
...

进入createClassLoader方法:

private WebappClassLoaderBase createClassLoader()
        throws Exception {

    // private String loaderClass = ParallelWebappClassLoader.class.getName();
    Class<?> clazz = Class.forName(loaderClass);
    WebappClassLoaderBase classLoader = null;

    if (parentClassLoader == null) {
        parentClassLoader = context.getParentClassLoader();
    }
    Class<?>[] argTypes = { ClassLoader.class };
    Object[] args = { parentClassLoader };
    Constructor<?> constr = clazz.getConstructor(argTypes);
    classLoader = (WebappClassLoaderBase) constr.newInstance(args);

    return classLoader;
}

该方法会实例化一个ParallelWebappClassLoader实例,并且传递了sharedLoader作为其父亲加载器。

代码阅读到这里,已经基本清楚了Tomcat中ClassLoader的总体结构,总结如下: 在Tomcat存在common、cataina、shared三个公共的classloader,默认情况下,这三个classloader其实是同一个,都是common classloader,而针对每个webapp,也就是context(对应代码中的StandardContext类),都有自己的WebappClassLoader实例来加载每个应用自己的类,该类加载实例的parent即是Shared ClassLoader。

这样前面关于tomcat的类加载层次应该就清楚起来了。

3.tomcat类加载器的加载过程

前面介绍了tomcat类加载器的创建及层次,下面进入本篇最后一点内容,这些类加载器是怎样加载类的呢?

所以重点看ParallelWebappClassLoader(看名字就是并行的WebappClassLoader,具体的差异就不做研究了)。

ParallelWebappClassLoader的loadClass是在其父类WebappClassLoaderBase中实现的:

第一步:

首先调用findLoaderClass0() 方法检查WebappClassLoader中是否加载过此类。

WebappClassLoader 加载过的类都存放在 resourceEntries 缓存中。protected final Map<String, ResourceEntry> resourceEntries = new ConcurrentHashMap<>();

第二步:

如果第一步没有找到,则继续检查JVM虚拟机中是否加载过该类。
调用ClassLoader的findLoadedClass()方法检查。

第三步:

如果前两步都没有找到,则使用系统类加载该类(也就是当前JVM的ClassPath)。为了防止覆盖基础类实现,这里会判断class是不是JVMSE中的基础类库中类。

protected ClassLoader getJavaseClassLoader() {
    return javaseClassLoader;
}

第四步:

先判断是否设置了delegate属性,设置为true,那么就会完全按照JVM的"双亲委托"机制流程加载类。

若是默认的话,是先使用WebappClassLoader自己处理加载类的。当然,若是委托了,使用双亲委托亦没有加载到class实例,那还是最后使用WebappClassLoader加载。

第五步:

若是没有委托,则默认会首次使用WebappClassLoader来加载类。通过自定义findClass定义处理类加载规则。

findClass()会去Web-INF/classes 目录下查找类。

去阅读里面的代码,里面有这样一个方法:

 @Override
public WebResource getClassLoaderResource(String path) {
    return getResource("/WEB-INF/classes" + path, true, true);
}

第六步:

若是WebappClassLoader在/WEB-INF/classes、/WEB-INF/lib下还是查找不到class,那么无条件强制委托给System、Common类加载器去查找该类。

最后借tomcat官网上的话总结一下:

Web应用类加载器默认的加载顺序是:

(1).先从缓存中加载;
(2).如果没有,则从JVM的Bootstrap类加载器加载;
(3).如果没有,则从当前类加载器加载(按照WEB-INF/classes、WEB-INF/lib的顺序);
(4).如果没有,则从父类加载器加载,由于父类加载器采用默认的委派模式,所以加载顺序是AppClassLoader、Common、Shared。

tomcat提供了delegate属性用于控制是否启用java委派模式,默认false(不启用),当设置为true时,tomcat将使用java的默认委派模式,这时加载顺序如下:

(1).先从缓存中加载;
(2).如果没有,则从JVM的Bootstrap类加载器加载;
(3).如果没有,则从父类加载器加载,加载顺序是AppClassLoader、Common、Shared。
(4).如果没有,则从当前类加载器加载(按照WEB-INF/classes、WEB-INF/lib的顺序);

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容