Java 多线程模型与并发设计

序言

上一次提到了Java 1.5中提供新的多线程模型,在大多数情况下,这已经能够满足日常开发的需要。但是偶尔也许觉得那一套模型还是觉得欠缺点什么,于是乎,Java 7/8中又提供了新的多线程模型。

Java 8中提供了并行流以及**ForkJoinPool **(FJP)和lambda(据说Java 8的lambda只是语法糖,没有深究过)

ForkJoinPool / ForkJoinTask

这一套工具是由Java 7提供的。要使用这种方法之前,应该有所了解函数式编程,如果有过JavaScript或者其他一个脚本语言的开发,应该对此不会陌生。另外,通过这种方法,比较难确定实际上是否使用了超过一个线程,因为这是由的具体实现决定的。最后,在默认情况下是通过ForkJoinPool.commonPool()实现并行的。这个通用池由JVM来管理,并且被JVM进程内的所有线程共享。(以下示例代码若非特别说明均要求Java 7及以上,部分代码出于简洁,使用了lambda表达式,因此需要Java 8及以上才可以运行。可以将lambda表达式用匿名内部类替代,即可在Java 7下编译通过)

多线程经常会伴随着并行计算(并行不等于并发),虽然并不绝对,但是通常与并行或多或少存在着联系。而并行计算的特点在于将较为复杂、庞大的任务,拆解成互不相干、较为简单、小型的任务,最后将各个小任务的结果汇总、分析、处理,得到原本大任务的结果。这样做的目的无非是提高效率,充分利用硬件计算资源,有效规避瓶颈效应。

接下来以计算y! - x!为例,展示代码:

// 计算y! - x!的值
class MyJob extends RecursiveTask<Integer> {

    private int y;
    private int x;

    public MyJob(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    protected Integer compute() {
        if (x > 2) { // 先计算x的阶乘
            MyJob subJobX = new MyJob(x - 1, -1);
            subJobX.fork();
            x *= subJobX.join();

            if (y == -1) { // 判断是否为递归计算
                return x; // 递归计算则返回阶乘结果
            } else if (y > 2) { // 计算y的阶乘
                MyJob subJobY = new MyJob(y - 1, -1);
                subJobY.fork();
                y *= subJobY.join();

                return y - x; // 输出最后任务的结果
            } else { // 正常输入,不会进入这个分支
                System.err.println("Error.");
                return 0;
            }
        } else {
            return 2;
        }
    }
}

public class ForkJoinExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool();

        MyJob job = new MyJob(4, 10);

        Future<Integer> result = forkJoinPool.submit(job);

        while (!result.isDone()) {
            System.err.println("Waiting for result");
            Thread.sleep(0, 1);
        }
        forkJoinPool.shutdown();
        System.err.println("The results is " + result.get());
    }
}

输出结果:

Waiting for result
Waiting for result
The results is 3628776

在这个例子中,我把计算n * (n - 1)作为最小的任务。因此先判断x是否大于2,如果不大于2,则fork出一个分支,计算(x - 1)!的值,y同理。最后得到x!与y!的值,相减,得出最后的结果。代码中,fork出来的分支中,参数y我作为一个标志,如果为-1则不是原始调用,而是fork的子任务,只需要负责计算传递进来x的阶乘即可。

这段代码中,你看不出任务在哪里完成(哪个线程)、由谁完成、什么时候完成。正如前面所述,甚至你很难看出来是否是大于一个线程在执行。

当然你可以尝试在compte方法中增加System.err.printf("%s is running.%n",Thread.currentThread().getName());的语句来查看输出,到底有几个线程在运行。不过根据我的实践来说,一般情况下,应该只有一个线程在运行。这不是说代码有问题,主要有两个因素:

  • 代码设计不合理,我的代码中,计算x的阶乘与y的阶乘是分开的,并没有一起fork,因此本质上并发性其实没有显示出来。我的代码其实相当于先计算x!,然后计算y!,最后计算y! - x!
  • 由于例子中代码的计算量很小,以当前CPU的计算能力有盈余。针对这种情况,可以尝试多开几个任务同时并行查看输出结果。

当然我觉得上面的例子不好,因此想了另一张场景,并用代码演示一下。

比如现在需要制作一个网络爬虫,爬什么呢,就爬简述首页推荐文章每篇文章的字数。代码中涉及网络请求和正则表达式的部分就不说明了,其中用了我自己写的一个小工具类Spider.javaHttpRequester.java

class JianshuSpiderJob extends RecursiveTask<List<String>> {
    private static final String HOST = "http://www.jianshu.com";
    private String url;

    public JianshuSpiderJob() {
        this(HOST);
    }

    protected JianshuSpiderJob(String url) {
        this.url = url;
    }

    protected List<String> requestHomepage() throws IOException {
        List<String> result = new ArrayList<>();

        Spider.newHost(new URL(this.url)).get((responseCode, responseHeaders, responseStream) -> { // 请求简书主页
            if (responseCode == 200) {
                Pattern indexPattern = Pattern.compile("(/p/[a-z0-9]+)\">([^<>]+)</a></h4>");
                Matcher indexMatcher = indexPattern.matcher(responseStream.toString());

                // 从这里开始派分子任务
                List<JianshuSpiderJob> subJobs = new ArrayList<>();
                while (indexMatcher.find()) {
                    String subUrl = indexMatcher.group(1);
                    String subTitle = indexMatcher.group(2);
                    JianshuSpiderJob subJob = new JianshuSpiderJob(subUrl);
                    subJob.fork();
                    subJobs.add(subJob);
                    result.add(String.format("%s=%s", subTitle, subUrl));
                }

                // 衔接子任务的结果
                for (JianshuSpiderJob job : subJobs) {
                    List<String> list = job.join();
                    if (list.size() > 0) {
                        String[] subResult = list.get(0).split("=");
                        for (int i = 0; i < result.size(); i++) {
                            if (result.get(i).indexOf(subResult[0]) > 0) {
                                String[] localResult = result.get(i).split("=");
                                result.remove(i);
                                result.add(String.format("%s=%s", localResult[0], subResult[1]));
                                break;
                            }
                        }
                    }
                }
            } else { // 网络错误
                System.err.println("There is an error when trying to get homepage.");
            }
            return responseCode;
        });

        return result; // 返回最终结果
    }

    protected List<String> requestSubPage() throws IOException {
        List<String> result = new ArrayList<>();
        Map<String, String> requestHeader = new HashMap<>();
        requestHeader.put("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.71 Safari/537.36");
        // 获取文章页面的详细信息
        Spider.newHost(new URL(HOST + this.url))
                .setRequestHeaders(requestHeader)
                .get((responseCode, responseHeaders, responseStream) -> { // 请求具体的文章页
                    String html = responseStream.toString();
                    Pattern contextPattern = Pattern.compile("\"slug\":\"([a-z0-9]+)\".*?\"wordage\":(\\d+)");
                    Matcher contextMatcher = contextPattern.matcher(html);
                    if (contextMatcher.find())
                        result.add(String.format("%s=%s", contextMatcher.group(1), contextMatcher.group(2)));
                    return responseCode;
                });
        return result;
    }

    @Override
    protected List<String> compute() {
        try {
            System.err.printf("%s is running.%n", Thread.currentThread().getName()); // 显示当前工作线程
            if (HOST.equals(this.url)) {
                return this.requestHomepage();
            } else {
                return this.requestSubPage();
            }
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}

public class ForkJoinExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool();

        JianshuSpiderJob job = new JianshuSpiderJob();
        forkJoinPool.submit(job);

        List<String> result = job.get();

        // 输出结果
        System.err.println("Article\tWords");
        for (String str : result) {
            String[] s = str.split("=");
            if (s.length > 1)
                System.err.printf("%s\t%s%n", s[0], s[1]);
            else
                System.err.println("Err -> " + str);
        }
        forkJoinPool.shutdown();

    }
}

这里例子相比较之前那个计算阶乘比较有典型,因为网络请求本身是阻塞。一个请求可能几毫秒就可以返回,也可以几秒钟才返回,甚至等了几秒钟以后,连接被中断,请求失败。例子中,并没有考虑网络异常的情况下。

运行结果:

ForkJoinPool-1-worker-1 is running.
ForkJoinPool-1-worker-1 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-2 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-2 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-6 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-2 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-7 is running.
ForkJoinPool-1-worker-4 is running.
ForkJoinPool-1-worker-3 is running.
ForkJoinPool-1-worker-0 is running.
ForkJoinPool-1-worker-4 is running.
Article Words
又一年轻姑娘离世:请记住这些“1+1=死神”的药物!  2862
报告大王,新概念英语一至四册全套资源(视频、音频、电子书)已被我活捉! 861
模式学习|找到最适合你的学习“套路”  4009
我们女孩子真不容易,既要貌美如花,又要赚钱养家。    1603
二十岁出头的你,别急着想要出人头地   2787
别怕,谁的大学不迷茫  1649
这10年,多少人从郭敬明到咪蒙 3143
老实人浪起来,你我都招架不住  2165
如果没有回报,我会坚持写作多久 2595
简书早报161030——《不负责任吐槽,四本买完看完就后悔的畅销书》  2029
时而自信,时而自卑,如何改变这种双重人生?   2855
有哪些小乐器,是学习起来非常方便的?  4259
穷人,最可怕的是总说自己穷   1838
时光回去,只愿未曾遇到你(五十七)   3109
亲爱的,千万别把孩子养得“输不起”!  2580
你凭什么诋毁我的爱豆!向语言暴力Say No! 2177
史上最全36个虐腹动作:想要马甲线,人鱼线的朋友练起来 776
两只蜗牛的爱情 3382
比文招亲【星言夙驾,说于桑田】十二   2493
《简书历史月刊003·三千年来谁著史》上线   1118

从结果来看,整个过程最多的时候使用了8个线程来完成这个任务。每次运行具体使用的线程数都不一样,读者也可以将这段代码复制过去,并引用连接中的两个类,看看结果如何。(由于运行结果依赖于简书服务器返回的结果,随着时间推移,程序结果很可能不正确,望知悉)

在整个代码中,程序员并不知道具体任务是如何分配,程序员的关注点只在业务逻辑本身上,而不用关心有关于线程调度的问题。具体的调度交给里FJP。

ForkJoinTask 中抛出异常

而在ForkJoinTask,可能会引发Unchecked Exception,因此可以调用ForkJoinTask.isCompletedAbnormally()来判断是否任务在执行中出现异常。如果返回值为Throwable类型则表明在执行过程中出现Unchecked Exception;若返回值为CancellationException则表明任务在执行过程中被取消;如果任务还没有结束或者正常完成,没有异常,则返回null。

ForkJoinPool / ForkJoinTask 与 Executor 关联

从类继承图上可以看到,ForkJoinPool 间接继承了Executor,因此可以认为两者师出同门,只不过后者提供更加便捷API,使程序员将关注点更加集中在业务上。既然两者师承一派,那么很多地方是一样或类似的,这里着重说一下不同的地方。

区别 Executor ForkJoinPool
接受的对象 Runnable和Callable的实例 Runnable、Callable和ForkJoinTask的实例
调度模式 处于后面等待中的任务需要等待前面任务执行后才有机会被执行,是否被执行取决于具体的调度规则 采用work-stealing模式帮助其他线程执行任务,即ExcuteService解决的是并发问题,而ForkJoinPool解决的是并行问题。

对于了解类UNIX系统的人来说,对于fork这个词应该不会陌生。这里fork的含义基本相同,即一个大任务分支出多个小任务执行,而小任务的执行过程中可能还会分支出更小的任务,如此往复,直到分支出来的任务是原子任务。

而join是等待刚才fork出去的分支,返回结果。顺序与fork正好相反,执行结果不断的join向上,最后那个大任务的结果就出来了。

其实FJP中还有一个Actor模型,但是我没用过,就不介绍了,感兴趣的可以善用搜索引擎。

Java 8 中的Stream

这个Stream不同于OIO中的Stream,不是一种输出/输出流,其本身不包含任何数据,更像一种迭代器。这在后面的例子中会提现出来,这个Stream允许并行的对集合类型进行迭代操作,并且依托于lambda表达式,可以用极为简便的代码完成对集合的CRUD操作。而Stream之所以能够提供并行迭代的,是因为其内部使用了FJP的模型(以下代码若非特别说明均需要Java 8及以上)

一般来说,使用一个Stream的流程是:

  1. 取得一个数据源 source
  2. 数据转换
  3. CRUD操作
  4. 返回新的Stream

Stream不会改变数据源,每次都会返回一个新的数据集合。而数据源可以是这些来源:

  • Collection 对象
    • Collection.stream()
    • Collection.parallelStream()
  • 数组
    • Arrays.stream(T array)
    • Stream.of()
  • BufferedReader
    • BufferedReader.lines()
  • java.util.stream.IntStream.range()
  • java.nio.file.Files.walk()
  • java.util.Spliterator
  • Random.ints()
  • BitSet.stream()
  • Pattern.splitAsStream(java.lang.CharSequence)
  • JarFile.stream()

Stream的操作大致分为两大列:

  • Intermediate,一个Stream后面可以跟随任意个Intermediate操作。其主要目的过过滤、映射数据,值得一提的是intermediate是lazy的,因此只有调用相关方法后才会进行相关Stream的真正操作(例如打开文件等)
  • Terminal,一个Stream只能有一个Terminal。一旦执行操作后,这个Stream就已经结束了,因此Terminal一定是一个Stream的最后一个操作。Terminal的调用才会真正开始Stream的遍历,并且会产生一个结果。

Stream的使用方法

理论安利的半天,看看比较直观的代码,比如从随机数中找到大于x的值:(输出大于50的数字)

public class StreamExample {
    public static void main(String[] args) {
        IntStream stream = new Random().ints(0, 100).limit(50); // 构造Stream,生成50个[0,100)之间随机数,这行代码结束的时候,数字还没有生成

        stream.filter(value -> value > 50) // 此时随机数还没有生成
                .forEach(System.out::println); // 直到要输出的时候,才从数据源获取数据
    }
}

代码有没有很简洁?传统方法需要各种各样的for循环,这里全部没有了。首先要格外强调的是:

  • Stream是延迟操作的
  • Stream本身是不包含任何数据
  • Stream的数据均来自于数据源
  • Stream只有执行Terminal操作时,才从数据源上获取数据
  • Stream的(输入)数据源可以是无穷大的
  • Stream的输出不能是无穷的,必须是一个有限集合

这里举个例子,说明一下数据源可以是无限的。常规的集合,数组、列表等都是有限集合,集合可以是非常大(受限于硬件限制),但必定有限。什么是无限的集合?数学上有个概念,叫自然数,定义是所有正整数加上0的集合,而正整数这个子集合是无穷的。那么在Java中如何表示这个无限集合 自然数呢?

class NaturalNumber implements Supplier<BigInteger> {
    private BigInteger num;

    public NaturalNumber() {
        this.num = BigInteger.valueOf(-1);
    }

    @Override
    public BigInteger get() {
        this.num = this.num.add(BigInteger.ONE);
        return this.num;
    }
}

这样就构造了一个无限的自然数集合,通过Stream.generate()方法来构建与这个无限集合相关的Stream对象,Stream每次获取值或调用get方法,无穷无尽。另外还有一个更简便的无穷的自然数集合,只有一句话:

Stream.iterate(0, val -> val + 1);

不过实际上这个有有穷的集合,受限于Integer数据类型的限制,最大只能到Integer.MAX_VALUE

那么什么是输出不能是无穷的呢?有输入,就可以输出,为什么不能无限输出呢?以这个自然数发生器来看个例子:

public class StreamExample {
    public static void main(String[] args) {
        Stream.generate(new NaturalNumber()).forEach(System.err::println);
    }
}

编译没有问题,运行起来也没有问题。但是...似乎程序永远也不会停下来,因为Stream能够得到无穷的输入,那么就可以无尽的输出。永不停歇,大多数情况下,我们不希望程序会这样,同样以这个自然数发生器为例,可能我希望计算从m到n自然数的累加值。但是数据源是无限的,怎么办?

public class StreamExample {
    static class FinalFieldHelper<T> {
        private T obj;

        public FinalFieldHelper(T obj) {
            this.obj = obj;
        }

        public T value() {
            return this.obj;
        }

        public void value(T obj) {
            this.obj = obj;
        }
    }

    public static void main(String[] args) {
        final int m = 10000, n = 100000;
        final FinalFieldHelper<BigInteger> result = new FinalFieldHelper<>(BigInteger.ZERO);
        Stream.generate(new NaturalNumber()).limit(n).skip(m).forEach(bigInteger -> result.value(bigInteger.add(result.value())));

        System.err.printf("from %d to %d -> %s%n",m,n,result.value().toString());
    }
}

是的,正如你所见的那样,使用limit方法,将一个无限集合截取成有限集合,然后再进行操作。因为对于无限集合而言,调用任何一个Terminal操作都会导致程序挂起。(FinalFieldHelper是一个辅助类,因为内部类访问外部类的变量必须是final的,所以在这里我无法更新result的值,用了这么个类变通一下)

这里介绍一下Stream的一些常用方法

方法 用途
distinct 去除重复对象,其结果依赖于具体对象的equals方法
filter 过滤数据源中的结果,产生新的Stream,参数为过滤的方法
map 对于Stream中包含的元素使用给定的转换函数进行转换操作,新生成的Stream只包含转换生成的元素。这个方法有三个对于原始类型的变种方法,分别是:mapToInt,mapToLong和mapToDouble。这三个方法也比较好理解,比如mapToInt就是把原始Stream转换成一个新的Stream,这个新生成的Stream中的元素都是int类型。之所以会有这样三个变种方法,可以免除自动装箱/拆箱的额外消耗;
flatMap 和map类似,不同的是其每个元素转换得到的是Stream对象,会把子Stream中的元素压缩到父集合中;
peek 生成一个包含原Stream的所有元素的新Stream,同时会提供一个消费函数(Consumer实例),新Stream每个元素被消费的时候都会执行给定的消费函数;
limit 对一个Stream进行截断操作,获取其前N个元素,如果原Stream中包含的元素个数小于N,那就获取其所有的元素;
skip 返回一个丢弃原Stream的前N个元素后剩下元素组成的新Stream,如果原Stream中包含的元素个数小于N,那么返回空Stream;

下面就这些常用方法,写一些对应的例子

public class StreamTestCase {
    private final Object[] source = new Object[]{"a", "b", null, "c", new String[]{"d1", "d2", "d3"}, "e", "a", "b", "c", "f"};

    private void printForEach(String methodName, Stream stream) {
        if (methodName != null)
            System.err.printf("===%s Start===%n", methodName);
        System.err.print('[');
        stream.forEach(o -> {
            if (o == null)
                System.err.print(o);
            else if (o instanceof Stream)
                this.printForEach(null, (Stream) o);
            else if (o instanceof String || o instanceof Boolean)
                System.err.print(o);
            else {
                Object[] obj = (Object[]) o;
                System.err.print('[');
                for (Object oo : obj) {
                    System.err.print(oo);
                    System.err.print(' ');
                }
                System.err.print(']');
            }
            System.err.print(' ');
        });
        System.err.println(']');
        if (methodName != null)
            System.err.printf("===%s Finish===%n", methodName);
    }

    @Test
    public void distinctTest() {
        this.printForEach("distinctTest", Stream.of(source).distinct()); // 去掉重复元素,后面的abc就被去掉了
    }

    @Test
    public void peekTest() {
        this.printForEach("piikTest", Stream.of(source).peek(o -> System.err.println("Peek -> " + o))); // 一定要有终端方法,peek才会被调用
    }

    @Test
    public void filterTest() {
        this.printForEach("filterTest", Stream.of(source).filter(o -> o != null && o instanceof String)); // 过滤掉了null和数组
    }

    @Test
    public void limitTest() {
        this.printForEach("limitTest", Stream.of(source).limit(4)); // 截取前四个元素
    }

    @Test
    public void skipTest() {
        this.printForEach("skipTest", Stream.of(source).skip(4)); // 去掉前四个元素
    }

    @Test
    public void mapTest() {
        // 将源对象根据自定义规则进行类型转换
        // 我的转化规则是null保持不变
        // 其他元素非String的转换为True
        // String类型,首字母Ascii码为偶数的为True 其余false
        this.printForEach("mapTest", Stream.of(source).map(o -> {
            if (o == null) return null;
            if (o.getClass().isArray()) return Boolean.TRUE;
            return (o.toString().charAt(0) & 1) == 0;
        }));
    }

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,580评论 18 139
  • 一、多线程 说明下线程的状态 java中的线程一共有 5 种状态。 NEW:这种情况指的是,通过 New 关键字创...
    Java旅行者阅读 4,655评论 0 44
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,392评论 25 707
  • SET答辩失败原因 1.耗时过长。在通知了答辩时间控制在30分钟内,PPT演讲时间控制在10分钟的情况下,仍然不注...
    三笑奈若何阅读 208评论 0 0
  • 本周小结 本周实验室的工作上周的用户周一反馈说,下载的模板书里数据不对,周一操作测试环境的数据库给解决了一下,同时...
    im天行阅读 149评论 0 1