饭饭TXT > 学习管理 > 《实战Java高并发程序设计(出书版)》作者:葛一鸣/郭超【完结】 > 实战Java高并发程序设计.txt

第6章 Java 8与并发

作者:葛一鸣/郭超 当前章节:15380 字 更新时间:2026-6-23 07:00

2014年,Oracle发布了Java 8新版本。对于Java来说,这显然是一个具有里程碑意义的版本。它最主要的改进是增加了函数式编程的功能。就目前来说,Java最令人头痛的问题,也是受到最多质疑的地方,应该就是Java那烦琐的语法。这样我们不得不花费大量的代码行数,来实现一些司空见惯的功能,以至于Java程序总是冗长的。但是,这一切将在Java 8的函数式编程中得到缓解。

严格来说,函数式编程与我们的主题并没有太大关系,我似乎不应该在这里提及它。但是,在Java 8中新增的一些与并行相关的API,却以函数式编程的范式出现,为了能让大家更好地理解这些功能,我会先简要地介绍一下Java 8中的函数式编程。

6.1 Java 8的函数式编程简介

函数式编程与面向对象的设计方法在思路和手段上都各有千秋,在这里,我将简要介绍一下函数式编程与面向对象相比较的一些特点和差异。

6.1.1 函数作为一等公民

在理解函数作为一等公民这句话时,让我们先来看一下一种非常常用的互联网语言JavaScript,相信大家对它都不会陌生。JavaScript并不是严格意义上的函数式编程,不过,它也不是属于严格的面向对象。但是,如果你愿意,你既可以把它当作面向对象语言,也可以把它当作函数式语言,因此,称之为多范式语言,可能更加合适。

如果你使用jQuery,你可能会经常使用如下的代码:

$("button").click(function(){ $("li").each(function(){ alert($(this).text()) }); });

注意这里each()函数的参数,这是一个匿名函数,在遍历所有的li节点时,会弹出li节点的文本内容。将函数作为参数传递给另外一个函数,这是函数式编程的特性之一。

再来考察另外一个案例:

function f1(){ var n=1; function f2(){ alert(n); } return f2; } var result=f1(); result(); // 1

这也是一段JavaScript代码。在这段代码中,注意函数f1的返回值,它返回了函数f2。在倒数第2行,返回的f2函数并赋值给result,实际上,此时的result就是一个函数,并且指向f2。对result的调用,就会打印n的值。

函数可以作为另外一个函数的返回值,也是函数式编程的重要特点。

6.1.2 无副作用

函数的副作用指的是函数在调用过程中,除了给出了返回值外,还修改了函数外部的状态。比如,函数在调用过程中,修改了某一个全局状态。函数式编程认为,函数的副用作应该被尽量避免。可以想象,如果一个函数肆意修改全局或者外部状态,当系统出现问题时,我们可能很难判断究竟是哪个函数引起的问题,这对于程序的调试和跟踪是没有好处的。如果函数都是显式函数,那么函数的执行显然不会受到外部或者全局信息的影响,因此,对于调试和排错是有益的。

注意:显式函数指函数与外界交换数据的唯一渠道就是参数和返回值,显式函数不会去读取或者修改函数的外部状态。与之相对的是隐式函数,隐式函数除了参数和返回值外,还会读取外部信息,或者可能修改外部信息。

然而,完全的无副作用实际上做不到的,因为系统总是需要获取或者修改外部信息的,同时,模块之间的交互也极有可能是通过共享变量进行的。如果完全禁止副作用的出现,也是一件让人很不愉快的事情。因此,大部分函数式编程语言,如Clojure等,都允许副作用的存在。但是与面向对象相比,这种函数调用的副作用,在函数式编程里,需要进行有效的限制。

6.1.3 申明式的(Declarative)

函数式编程是申明式的编程方式。相对于命令式(Imperative)而言,命令式的程序设计喜欢大量使用可变对象和指令。我们总是习惯于创建对象或者变量,并且修改它们的状态或者值,或者喜欢提供一系列指令,要求程序执行。这种编程习惯在申明式的函数式编程中有所变化。对于申明式的编程范式,你不再需要提供明确的指令操作,所有的细节指令将会更好地被程序库所封装,你要做的只是提出你的要求,申明你的用意即可。

请看下面一段程序,这一段传统的命令式编程,为了打印数组中的值,我们需要进行一个循环,并且每次需要判断循环是否结束。在循环体内,我们要明确地给出需要执行的语句和参数。

public static void imperative(){ int[] iArr={1,3,4,5,6,9,8,7,4,2}; for(int i=0;i<iArr.length;i++){ System.out.println(iArr[i]); } }

与之对应的申明式代码如下:

public static void declarative(){ int[] iArr={1,3,4,5,6,9,8,7,4,2}; Arrays.stream(iArr).forEach(System.out::println); }

可以看到,变量数组的循环体居然消失了!println()函数似乎在这里也没有指定任何参数,在此,我们只是简单地申明了我们的用意。有关循环以及判断循环是否结束等操作都被简单地封装在程序库中。

6.1.4 不变的对象

在函数式编程中,几乎所有传递的对象都不会被轻易修改。

请看以下代码:

static int[] arr={1,3,4,5,6,7,8,9,10}; Arrays.stream(arr).map((x)->x=x+1).forEach(System.out::println); System.out.println(); Arrays.stream(arr).forEach(System.out::println);

代码第2行看似对每一个数组成员执行了加1的操作。但是在操作完成后,在最后一行,打印arr数组所有的成员值时,你还是会发现,数组成员并没有变化!在使用函数式编程时,这种状态是一种常态,几乎所有的对象都拒绝被修改。这非常类似于不变模式。

6.1.5 易于并行

由于对象都处于不变的状态,因此函数式编程更加易于并行。实际上,你甚至完全不用担心线程安全的问题。我们之所以要关注线程安全,一个很重要的原因是当多个线程对同一个对象进行写操作时,容易将这个对象“写坏”。但是,由于对象是不变的,因此,在多线程环境下,也就没有必要进行任何同步操作。这样不仅有利于并行化,同时,在并行化后,由于没有同步和锁机制,其性能也会比较好。

6.1.6 更少的代码

通常情况下,函数式编程更加简明扼要,Clojure语言(一种运行于JVM的函数式语言)的爱好者就宣称,使用Clojure可以将Java代码行数减少到原有的十分之一。一般说来,精简的代码更易于维护。引入函数式编程范式后,我们可以使用Java用更少的代码完成更多的工作。

请看下面这个例子,对于数组中每一个成员,首先判断是否是奇数,如果是奇数,则执行加1,并最终打印数组内所有成员。

数组定义:

static int[] arr={1,3,4,5,6,7,8,9,10};

传统的处理方式:

for(int i=0;i<arr.length;i++){ if(arr[i]%2!=0){ arr[i]++; } System.out.println(arr[i]); }

使用函数式方式:

Arrays.stream(arr).map(x->(x%2==0?x:x+1)).forEach(System.out::println);

可以看到,函数式范式更加紧凑而且简洁。

6.2 函数式编程基础

在正式进入函数式编程之前,有必要先了解一下Java 8为支持函数式编程所做的基础性的改进,这里,将简要介绍一下FunctionalInterface注释、接口默认方法和方法句柄。

6.2.1 FunctionalInterface注释

Java 8提出了函数式接口的概念。所谓函数式接口,简单来说,就是只定义了单一抽象方法的接口。比如下面的定义:

@FunctionalInterface public static interface IntHandler{ void handle(int i); }

注释FunctionalInterface用于表明IntHandler接口是一个函数式接口,该接口被定义为只包含一个抽象方法handle(),因此它符合函数式接口的定义。如果一个函数满足函数式接口的定义,那么即使不标注为@FunctionalInterface,编译器依然会把它看做函数式接口。这有点像@Override注释,如果你的函数符合重载的要求,无论你是否标注了@Override,编译器都会识别这个重载函数,但一旦你进行了标注,而实际的代码不符合规范,那么就会得到一个编译错误。如图6.1所示,展示了一个不符合规范,却被标注为@FunctionalInterfacede接口。很显然,该IntHandler包含两个抽象方法,因此不符合函数式接口的要求,又因为IntHandler接口被标注为函数式接口,产生矛盾,故编译出错。

图6-1 不符合规范的函数式接口

这里需要强调的是,函数式接口只能有一个抽象方法,而不是只能有一个方法。这分两点来说明:首先,在Java 8中,接口运行存在实例方法(参见下节的“接口默认方法”),其次任何被java.lang.Object实现的方法,都不能视为抽象方法,因此,下面的NonFunc接口不是函数式接口,因为equals()方法在java.lang.Object中已经实现。

interface NonFunc { boolean equals(Object obj); }

同理,下面实现的IntHandler接口符合函数式接口要求,虽然看起来它不像,但实际上它是一个完全符合规范的函数式接口。

@FunctionalInterface public static interface IntHandler{ void handle(int i); boolean equals(Object obj); }

函数式接口的实例可以由方法引用或者lambda表达式进行构造,这个我们将在后面进一步举例说明。

6.2.2 接口默认方法

在Java 8之前的版本,接口只能包含抽象方法。但从Java 8之后,接口也可以包含若干个实例方法。这一改进使得Java 8拥有了类似于多继承的能力。一个对象实例,将拥有来自于多个不同接口的实例方法。

比如,对于接口IHorse,实现如下:

public interface IHorse{ void eat(); default void run(){ System.out.println("hourse run"); } }

在Java 8中,使用default关键字,可以在接口内定义实例方法。注意,这个方法并非抽象方法,而是拥有特定逻辑的具体实例方法。

所有的动物都能自由呼吸,所以,这里可以再定义一个IAnimal接口,它也包含一个默认方法breath()。

public interface IAnimal { default void breath(){ System.out.println("breath"); } }

骡是马和驴的杂交物种,因此骡(Mule)可以实现为IHorse,同时骡也是动物,因此有:

public class Mule implements IHorse,IAnimal{ @Override public void eat() { System.out.println("Mule eat"); } public static void main(String[] args) { Mule m=new Mule(); m.run(); m.breath(); } }

注意上述代码中Mule实例同时拥有来自不同接口的实现方法。这在Java 8之前是做不到的。从某种程度上说,这种模式可以弥补Java单一继承的一些不便。但同时也要知道,它也将遇到和多继承相同的问题,如图6.2所示。如果IDonkey也存在一个默认的run()方法,那么同时实现它们的Mule,就会不知所措,因为它不知道应该以哪个方法为准。

图6-2 接口默认方法带来的多继承问题

增加一个IDonkey的实现:

public interface IDonkey{ void eat(); default void run(){ System.out.println("Donkey run"); } }

修改骡Mule的实现如下,注意它同时实现了IHorse和IDonkey:

public class Mule implements IHorse,IDonkey,IAnimal{ @Override public void eat() { System.out.println("Mule eat"); } public static void main(String[] args) { Mule m=new Mule(); m.run(); m.breath(); } }

此时,由于IHorse和IDonkey拥有相同的默认实例方法,故编译器会抛出一个错误:

Duplicate default methods named run with the parameters () and () are inherited from the types IDonkey and IHorse

为了让Mule同时实现IHorse和IDonkey,在这里,我们不得不重新实现一下run()方法,让编译器可以进行方法绑定。修改Mule的实现如下:

public class Mule implements IHorse,IDonkey,IAnimal{ @Override public void run(){ IHorse.super.run(); } @Override public void eat() { System.out.println("Mule eat"); } public static void main(String[] args) { Mule m=new Mule(); m.run(); m.breath(); } }

在这里,将Mule的run()方法委托给IHorse实现,当然,大家也可以有自己的实现。

接口默认实现对于整个函数式编程的流式表达非常重要。比如,大家熟悉的java.util.Comparator接口,它在JDK 1.2时就已经被引入,用于在排序时给出两个对象实例的具体比较逻辑。在Java 8中,Comparator接口新增了若干个默认方法,用于多个比较器的整合。其中一个常用的默认方法如下:

default Comparator<T> thenComparing(Comparator<? super T> other) { Objects.requireNonNull(other); return (Comparator<T> & Serializable) (c1, c2) -> { int res = compare(c1, c2); return (res != 0) ? res : other.compare(c1, c2); }; }

有了这个默认方法,在进行排序时,我们就可以非常方便地进行元素的多条件排序,比如,如下代码构造一个比较器,它先按照字符串长度排序,继而按照大小写不敏感的字母顺序排序。

Comparator<String> cmp = Comparator.comparingInt(String::length) .thenComparing(String.CASE_INSENSITIVE_ORDER);

6.2.3 lambda表达式

lambda表达式可以说是函数式编程的核心。lambda表达式即匿名函数,它是一段没有函数名的函数体,可以作为参数直接传递给相关的调用者。lambda表达式极大地增强了Java语言的表达能力。

下例展示了lambda表达式的使用,在forEach()函数中,传入的就是一个lambda表达式,它完成了对元素的标准输出操作。可以看到这段表达式并不像函数一样有名字,非常类似匿名内部类,它只是简单地描述了应该执行的代码段。

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6); numbers.forEach((Integer value) -> System.out.println(value));

和匿名对象一样,lambda表达式也可以访问外部的局部变量,如下所示:

final int num = 2; Function<Integer, Integer> stringConverter = (from) -> from * num; System.out.println(stringConverter.apply(3));

上述代码可以编译通过,正常执行,并输出6。与匿名内部对象一样,在这种情况下,外部的num变量必须申明为final,这样才能保证在lambda表达式中合法的访问它。

但奇妙的是,对于lambda表达式而言,即使去掉上述的final定义,程序依然可以编译通过!但千万不要以为这样你就可以修改num的值了。实际上,这只是Java 8做了一个掩人耳目的小处理,它会自动地将在lambda表达式中使用的变量视为final。因此,下述代码是可以编译通过的:

int num = 2; Function<Integer, Integer> stringConverter = (from) -> from * num; System.out.println(stringConverter.apply(3));

但是,如果像下面这么写,就不行:

int num = 2; Function<Integer, Integer> stringConverter = (from) -> from * num; num++; System.out.println(stringConverter.apply(3));

上述的num++会引起一个编译错误:

Local variable num defined in an enclosing scope must be final or effectively final

6.2.4 方法引用

方法引用是Java 8中提出的用来简化lambda表达式的一种手段。它通过类名和方法名来定位到一个静态方法或者实例方法。

方法引用在Java 8中的使用非常灵活。总的来说,可以分为以下几种。

静态方法引用:ClassName::methodName

实例上的实例方法引用:instanceReference::methodName

超类上的实例方法引用:super::methodName

类型上的实例方法引用:ClassName::methodName

构造方法引用:Class::new

数组构造方法引用:TypeName[]::new

首先,方法引用使用“::”定义,“::”的前半部分表示类名或者实例名,后半部分表示方法名称。如果是构造函数,则使用new表示。

下例展示了方法引用的基本使用:

public class InstanceMethodRef { public static void main(String[] args) { List<User> users=new ArrayList<User>(); for(int i=1;i<10;i++){ users.add(new User(i,"billy"+Integer.toString(i))); } users.stream().map(User::getName).forEach(System.out::println); } }

对于第1个方法引用“User::getName”,表示User类的实例方法。在执行时,Java会自动识别流中的元素(这里指User实例)是作为调用目标还是调用方法的参数。在“User::getName”中,显然流内的元素都应该作为调用目标,因此实际上,在这里调用了每一个User对象实例的getName()方法,并将这些User的name作为一个新的流。同时,对于这里得到的所有name,使用方法引用System.out::println进行处理。这里的System.out为PrintStream对象实例,因此,这里表示System.out实例的println方法,系统也会自动判断,流内的元素此时应该作为方法的参数传入,而不是调用目标。

一般来说,如果使用的是静态方法,或者调用目标明确,那么流内的元素会自动作为参数使用。如果函数引用表示实例方法,并且不存在调用目标,那么流内元素就会自动作为调用目标。

因此,如果一个类中存在同名的实例方法和静态函数,那么编译器就会感到很困惑,因为此时,它不知道应该使用哪个方法进行调用。它既可以选择同名的实例方法,将流内元素作为调用目标,也可以使用静态方法,将流元素作为参数。

请看下面的例子:

public class BadMethodRef { public static void main(String[] args) { List<Double> numbers=new ArrayList<Double>(); for(int i=1;i<10;i++){ numbers.add(Double.valueOf(i)); } numbers.stream().map(Double::toString).forEach(System.out::println); } }

上述代码试图将所有的Double元素转为String并将其输出,但是很不幸,在Double中同时存在以下两个函数:

public static String toString(double d) public String toString()

此时,对函数引用的处理就出现了歧义,因此,这段代码在编译时就会抛出如下错误:

Ambiguous method reference: both toString() and toString(double) from the type Double are eligible

方法引用也可以直接使用构造函数。首先,查看模型类User的定义:

public class User{ private int id; private String name; public User(int id,String name){ this.id=id; this.name=name; } //这里省略对字段的setter和getter }

下面的方法引用调用了User的构造函数:

public class ConstrMethodRef { @FunctionalInterface interface UserFactory<U extends User> { U create(int id, String name); } static UserFactory<User> uf=User::new; public static void main(String[] args) { List<User> users=new ArrayList<User>(); for(int i=1;i<10;i++){ users.add(uf.create(i, "billy"+Integer.toString(i))); } users.stream().map(User::getName).forEach(System.out::println); } }

在此,UserFactory作为User的工厂类,是一个函数式接口。当使用User::new创建接口实例时,系统会根据UserFactory.create()的函数签名来选择合适的User构造函数,在这里,很显然就是public User(int id,String name)。在创建UserFactory实例后,对UserFactory.create()的调用,都会委托给User的实际构造函数进行,从而创建User对象实例。

6.3 一步一步走入函数式编程

在了解了Java 8的一些新特性后,就可以正式开始进入函数式编程了。为了能让大家更快地理解函数式编程,我们先从简单的例子开始。

static int[] arr={1,3,4,5,6,7,8,9,10}; public static void main(String[] args) { for(int i:arr){ System.out.println(i); } }

上述代码循环遍历了数组内的元素,并且进行了数值的打印,这也是传统的做法。如果使用Java 8中的流,那么可以写成这样:

static int[] arr = { 1, 3, 4, 5, 6, 7, 8, 9, 10 }; public static void main(String[] args) { Arrays.stream(arr).forEach(new IntConsumer() { @Override public void accept(int value) { System.out.println(value); } }); }

注意:Arrays.stream()方法返回了一个流对象。类似于集合或者数组,流对象也是一个对象的集合,它将给予我们遍历处理流内元素的功能。

这里值得注意的是这个流对象的forEach()方法,它接收一个IntConsumer接口的实现,用于对每个流内的对象进行处理。之所以是IntConsumer接口,因为当前流是IntStream,也就是装有Integer元素的流,因此,它自然需要一个处理Integer元素的接口。函数forEach()会挨个将流内的元素送入IntConsumer进行处理,循环过程被封装在forEach()内部,也就是JDK框架内。

除了IntStream流外,Arrays.stream()还支持DoubleStream、LongStream和普通的对象流Stream,这完全取决于它所接受的参数,如图6.3所示。

图6.3 Stream流的几种类型

但这样的写法可能还不能让人满意,代码量似乎比原先更多,而且除了引入了不必要的接口和匿名类等复杂性外,似乎也看不出来有什么太大的好处。但是,我们的脚步并未就此打住。试想,既然forEach()函数的参数是可以从上下文中推导出来的,那为什么还要不厌其烦地写出来呢?这些机械的推导工作,就交给编译器去做吧!于是:

static int[] arr={1,3,4,5,6,7,8,9,10}; public static void main(String[] args) { Arrays.stream(arr).forEach((final int x)-> { System.out.println(x); }); }

从上述代码中可以看到,IntStream接口名称被省略了,这里只使用了参数名和一个实现体,看起来简洁很多了。但是还不够,因为参数的类型也是可以推导的。既然是IntConsumer接口,参数自然是int了,于是:

static int[] arr={1,3,4,5,6,7,8,9,10}; public static void main(String[] args) { Arrays.stream(arr).forEach((x)-> { System.out.println(x); }); }

好了,现在连参数类型也省略了,但是这两个花括号特别碍眼。虽然它们对程序没有什么影响,但是为了简单的一句执行语句要加上一对花括号也实属没有必要,那干脆也去掉吧!去掉花括号后,为了清晰起见,把参数申明和接口实现就放在一行吧!

static int[] arr={1,3,4,5,6,7,8,9,10}; public static void main(String[] args) { Arrays.stream(arr).forEach((x)->System.out.println(x)); }

这样看起来就好多了。此时,forEach()函数的参数依然是IntConsumer,但是它却以一种新的形式被定义,这就是lambda表达式。表达式由“->”分割,左半部分表示参数,右半部分表示实现体。因此,我们也可以简单地理解lambda表达式只是匿名对象实现的一种新的方式。实际上,也是这样的。

有兴趣的读者可以使用虚拟机参数-Djdk.internal.lambda.dumpProxyClasses启动带有lambda表达式的Java小程序,该参数会将lambda表达式相关的中间类型进行输出,方便调试和学习。在本例中,输出了HelloFunction6$$Lambda$1.class类,使用以下命令进行并发汇编操作:

javap -p -v HelloFunction6$$Lambda$1.class

在输出结果中,可以清楚地看到:

final class geym.java8.func.ch3.HelloFunction6$$Lambda$1 implements java.util.function.IntConsumer 省略部分输出 public void accept(int); descriptor: (I)V flags: ACC_PUBLIC Code: stack=1, locals=2, args_size=2 0: iload_1 1: invokestatic #17 // Method geym/java8/func/ch3/HelloFunction6.lambda$0:(I)V 4: return

限于篇幅有限,这里只给出了我们关心的内容。首先,这个中间类型确实实现了IntConsumer接口。其次,在实现accept()方法时,它内部委托给了一个名为HelloFunction6.lambda$0()的方法。可以推测,这个方法也是编译时自动生成的。

使用以下命令查看HelloFunction6的编译结果:

javap -p -v HelloFunction6

我们很惊喜地找到了期待已久的lambda$0()方法,其实现如下:

private static void lambda$0(int); descriptor: (I)V flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC Code: stack=2, locals=1, args_size=1 0: getstatic #41 // Field java/lang/System.out:Ljava/io/PrintStream; 3: iload_0 4: invokevirtual #47 // Method java/io/PrintStream.println:(I)V 7: return

它被实现为一个私有的静态方法,实现内容就是简单地进行了System.out.println()的调用,也正是我们代码中lambda表达式的内容。

由此,可以看到,Java 8中对lambda表达式的处理几乎等同于匿名类的实现,但是在写法上和编程范式上有了明显的区别。

不过,简化代码的流程并没有结束,在上一节中已经提到,Java 8还支持了方法引用,通过方法引用的推导,你甚至连参数申明和传递都可以省略。

static int[] arr={1,3,4,5,6,7,8,9,10}; public static void main(String[] args) { Arrays.stream(arr).forEach(System.out::println); }

至此,欢迎大家正式进入Java 8函数式编程的殿堂,那些看似玄妙的lambda表达式的解析和工作原理已经介绍完毕。

使用lambda表达式不仅可以简化匿名类的编写,与接口的默认方法相结合,还可以使用更顺畅的流式API对各种组件进行更自由的装配。

下面这个例子对集合中所有元素进行两次输出,一次输出到标准错误,一次输出到标准输出中。

static int[] arr={1,3,4,5,6,7,8,9,10}; public static void main(String[] args) { IntConsumer outprintln=System.out::println; IntConsumer errprintln=System.err::println; Arrays.stream(arr).forEach(outprintln.andThen(errprintln)); }

这里首先使用函数引用,直接定义了两个IntConsumer接口实例,一个指向标准输出,另一个指向标准错误。使用接口默认函数IntConsumer.addThen(),将两个IntConsumer进行组合,得到一个新的IntConsumer,这个新的IntConsumer会依次调用outprintln和errprintln,完成对数组中元素的处理。

其中IntConsumer.addThen()的实现如下,仅供大家参考:

default IntConsumer andThen(IntConsumer after) { Objects.requireNonNull(after); return (int t) -> { accept(t); after.accept(t); }; }

可以看到,addThen()方法返回一个新的IntConsumer,这个新的IntConsumer会先调用第1个IntConsumer进行处理,接着调用第2个IntConsumer处理,从而实现多个处理器的整合。这种操作手法在Java 8的函数式编程中极其常见,请大家留意。

6.4 并行流与并行排序

Java 8中,可以在接口不变的情况下,将流改为并行流。这样,就可以很自然地使用多线程进行集合中的数据处理。

6.4.1 使用并行流过滤数据

现在让我们考虑这么一个简单的案例,我们希望可以统计1~1000000内所有的质数的数量。首先,我们需要一个判断质数的函数:

目录
设置
设置
阅读主题
字体风格
雅黑 宋体 楷书 卡通
字体大小
适中 偏大 超大
保存设置
恢复默认
手机
手机阅读
扫码获取链接,使用浏览器打开
书架同步,随时随地,手机阅读
首 页 < 上一章 章节列表 下一章 > 尾 页