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

第5章 并行模式与算法.2

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

图5.4 X和Y在同一个缓存行中

为了使这种情况不发生,一种可行的做法就是在X变量的前后空间都先占据一定的位置(把它叫做padding吧,用来填充用的)。这样,当内存被读入缓存中时,这个缓存行中,只有X一个变量实际是有效的,因此就不会发生多个线程同时修改缓存行中不同变量而导致变量全体失效的情况,如图5.5所示。

图5.5 变量X和Y各占据一个缓冲行

为了实现这个目的,我们可以这么做:

01 public final class FalseSharing implements Runnable { 02 public final static int NUM_THREADS = 2; // change 03 public final static long ITERATIONS = 500L * 1000L * 1000L; 04 private final int arrayIndex; 05 06 private static VolatileLong[] longs = new VolatileLong[NUM_THREADS]; 07 static { 08 for (int i = 0; i < longs.length; i++) { 09 longs[i] = new VolatileLong(); 10 } 11 } 12 13 public FalseSharing(final int arrayIndex) { 14 this.arrayIndex = arrayIndex; 15 } 16 17 public static void main(final String[] args) throws Exception { 18 final long start = System.currentTimeMillis(); 19 runTest(); 20 System.out.println("duration = " + (System.currentTimeMillis() - start)); 21 } 22 23 private static void runTest() throws InterruptedException { 24 Thread[] threads = new Thread[NUM_THREADS]; 25 26 for (int i = 0; i < threads.length; i++) { 27 threads[i] = new Thread(new FalseSharing(i)); 28 } 29 30 for (Thread t : threads) { 31 t.start(); 32 } 33 34 for (Thread t : threads) { 35 t.join(); 36 } 37 } 38 39 public void run() { 40 long i = ITERATIONS + 1; 41 while (0 != --i) { 42 longs[arrayIndex].value = i; 43 } 44 } 45 46 public final static class VolatileLong { 47 public volatile long value = 0L; 48 public long p1, p2, p3, p4, p5, p6,p7; // comment out 49 } 50 }

这里我们使用两个线程,因为我的计算机是双核的,大家可以根据自己的硬件配置修改参数NUM_THREADS(第2行)。我们准备一个数组longs(第6行),数组元素个数和线程数量一致。每个线程都会访问自己对应的longs中的元素(从第42行、第27行和第14行可以看到这一点)。

最后,最关键的一点就是VolatileLong。在第48行,准备了7个long型变量用来填充缓存。实际上,只有VolatileLong.value是会被使用的。而那些p1、p2等仅仅用于将数组中第一个VolatileLong.value和第二个VolatileLong.value分开,防止它们进入同一个缓存行。

这里,我使用JDK7 64位的Java虚拟机,执行上述程序,输出如下:

duration = 5207

这说明系统花费了5秒钟完成所有的操作。如果我注释掉第48行,也就是允许系统中两个VolatileLong.value放置在同一个缓存行中,程序输出如下:

duration = 13675

很明显,第48行的填充对系统的性能是非常有帮助的。

注意:由于各个JDK版本内部实现不一致,在某些JDK版本中(比如JDK 8),会自动优化不使用的字段。这将直接导致这种padding的伪共享解决方案失效。更多详细内容大家可以参考第6章中有关LongAddr的介绍。

Disruptor框架充分考虑了这个问题,它的核心组件Sequence会被非常频繁的访问(每次入队,它都会被加1),其基本结构如下:

class LhsPadding { protected long p1, p2, p3, p4, p5, p6, p7; } class Value extends LhsPadding { protected volatile long value; } class RhsPadding extends Value { protected long p9, p10, p11, p12, p13, p14, p15; }p ublic class Sequence extends RhsPadding{ //省略具体实现 }

虽然在Sequence中,主要使用的只有value。但是,通过LhsPadding和RhsPadding,在这个value的前后安置了一些占位空间,使得value可以无冲突的存在于缓存中。

此外,对于Disruptor的环形缓冲区RingBuffer,它内部的数组是通过以下语句构造的:

this.entries = new Object[sequencer.getBufferSize() + 2 * BUFFER_PAD];

大家注意,实际产生的数组大小是缓冲区实际大小再加上两倍的BUFFER_PAD。这就相当于在这个数组的头部和尾部两段各增加了BUFFER_PAD个填充,使得整个数组被载入Cache时不会受到其他变量的影响而失效。

5.5 Future模式

Future模式是多线程开发中非常常见的一种设计模式,它的核心思想是异步调用。当我们需要调用一个函数方法时,如果这个函数执行很慢,那么我们就要进行等待。但有时候,我们可能并不急着要结果。因此,我们可以让被调者立即返回,让它在后台慢慢处理这个请求。对于调用者来说,则可以先处理一些其他任务,在真正需要数据的场合再去尝试获得需要的数据。

Future模式有点类似在网上买东西。如果我们在网上下单买了一个手机,当我们支付完成后,手机并没有办法立即送到家里,但是在电脑上会立即产生一个订单。这个订单就是将来发货或者领取手机的重要凭证,这个凭证也就是Future模式中会给出的一个契约。在支付活动结束后,大家不会傻傻地等着手机到来,而是可以各忙各的。而这张订单就成为了商家配货、发货的驱动力。当然,这一切你并不用关心。你要做的,只是在快递上门时,开一下门,拿一下货而已。

对于Future模式来说,虽然它无法立即给出你需要的数据。但是,它会返回给你一个契约,将来,你可以凭借着这个契约去重新获取你需要的信息。

如图5.6所示,显示了通过传统的同步方法,调用一段比较耗时的程序。客户端发出call请求,这个请求需要相当长一段时间才能返回。客户端一直等待,直到数据返回,随后,再进行其他任务的处理。

图5-6 传统串行程序调用流程

使用Future模式替换原来的实现方式,可以改进其调用过程,如图5.7所示。

图5-7 Future模式流程图

下面的模型展示了一个广义Future模式的实现,从Data_Future对象可以看到,虽然call本身仍然需要很长一段时间处理程序。但是,服务程序不等数据处理完成便立即返回客户端一个伪造的数据(相当于商品的订单,而不是商品本身),实现了Future模式的客户端在拿到这个返回结果后,并不急于对其进行处理,而去调用了其他业务逻辑,充分利用了等待时间,这就是Future模式的核心所在。在完成了其他业务逻辑的处理后,最后再使用返回比较慢的Future数据。这样,在整个调用过程中,就不存在无谓的等待,充分利用了所有的时间片段,从而提高系统的响应速度。

5.5.1 Future模式的主要角色

为了让大家能够更清晰地认识Future模式的基本结构。在这里,我给出一个非常简单的Future模式的实现,它的主要参与者如表5.2所示。

表5.2 Future模式的主要参与者

它的核心结构如图5.8所示。

图5.8 Future模式结构图

5.5.2 Future模式的简单实现

在这个实现中,有一个核心接口Data,这就是客户端希望获取的数据。在Future模式中,这个Data接口有两个重要的实现,分别是RealData,也就是真实数据,这就是我们最终需要获得的,有价值的信息。另外一个就是FutureData,它就是用来提取RealData的一个“订单”。因此FutureData是可以立即返回得到的。

下面是Data接口:

public interface Data { public String getResult (); }

FutureData实现了一个快速返回的RealData包装。它只是一个包装,或者说是一个RealData的虚拟实现。因此,它可以很快被构造并返回。当使用FutrueData的getResult()方法时,如果实际的数据没有准备好,那么程序就会阻塞,等待RealData准备好并注入到FutureData中,才最终返回数据。

注意:FutureData是Future模式的关键。它实际上是真实数据RealData的代理,封装了获取RealData的等待过程。

public class FutureData implements Data { protected RealData realdata = null; //FutureData是RealData的包装 protected boolean isReady = false; public synchronized void setRealData(RealData realdata) { if (isReady) { return; } this.realdata = realdata; isReady = true; notifyAll(); //RealData已经被注入,通知getResult() } public synchronized String getResult() { //会等待RealData构造完成 while (!isReady) { try { wait(); //一直等待,知道RealData被注入 } catch (InterruptedException e) { } } return realdata.result; //由RealData实现 } }

RealData是最终需要使用的数据模型。它的构造很慢。在这里,使用sleep()函数模拟这个过程,简单地模拟一个字符串的构造。

public class RealData implements Data { protected final String result; public RealData(String para) { //RealData的构造可能很慢,需要用户等待很久,这里使用sleep模拟 StringBuffer sb=new StringBuffer(); for (int i = 0; i < 10; i++) { sb.append(para); try { //这里使用sleep,代替一个很慢的操作过程 Thread.sleep(100); } catch (InterruptedException e) { } } result =sb.toString(); } public String getResult() { return result; } }

接下来就是我们的客户端程序,Client主要实现了获取FutureData,并开启构造RealData的线程。并在接受请求后,很快的返回FutureData。注意,它不会等待数据真的构造完毕再返回,而是立即返回FutureData,即使这个时候FutureData内并没有真实数据。

public class Client { public Data request(final String queryStr) { final FutureData future = new FutureData(); new Thread() { public void run() { // RealData的构建很慢, //所以在单独的线程中进行 RealData realdata = new RealData(queryStr); future.setRealData(realdata); } }.start(); return future; // FutureData会被立即返回 } }

最后,就是我们的主函数Main,它主要负责调用Client发起请求,并消费返回的数据。

public static void main(String[] args) { Client client = new Client(); //这里会立即返回,因为得到的是FutureData而不是RealData Data data = client.request("name"); System.out.println("请求完毕"); try { //这里可以用一个sleep代替了对其他业务逻辑的处理 //在处理这些业务逻辑的过程中,RealData被创建,从而充分利用了等待时间 Thread.sleep(2000); } catch (InterruptedException e) { } //使用真实的数据 System.out.println("数据 = " + data.getResult()); }

5.5.3 JDK中的Future模式

Future模式是如此常用,因此JDK内部已经为我们准备好了一套完整的实现。显然,这个实现要比我们前面提出的方案复杂得多。在这里,我们将简单向大家介绍一下它的使用方式。

首先,让我们看一下Future模式的基本结构,如图5.9所示。其中Future接口就类似于前文描述的订单或者说是契约。通过它,你可以得到真实的数据。RunnableFuture继承了Future和Runnable两个接口,其中run()方法用于构造真实的数据。它有一个具体的实现FutureTask类。FutureTask有一个内部类Sync,一些实质性的工作,会委托Sync类实现。而Sync类最终会调用Callable接口,完成实际数据的组装工作。

图5.9 JDK内置的Future模式

Callable接口只有一个方法call(),它会返回需要构造的实际数据。这个Callable接口也是这个Future框架和应用程序之间的重要接口。如果我们要实现自己的业务系统,通常需要实现自己的Callable对象。此外,FutureTask类也与应用密切相关,通常,我们会使用Callable实例构造一个FutureTask实例,并将它提交给线程池。

下面我们将展示这个内置的Future模式的使用:

01 public class RealData implements Callable<String> { 02 private String para; 03 public RealData(String para){ 04 this.para=para; 05 } 06 @Override 07 public String call() throws Exception { 08 09 StringBuffer sb=new StringBuffer(); 10 for (int i = 0; i < 10; i++) { 11 sb.append(para); 12 try { 13 Thread.sleep(100); 14 } catch (InterruptedException e) { 15 } 16 } 17 return sb.toString(); 18 } 19 }

上述代码实现了Callable接口,它的call()方法会构造我们需要的真实数据并返回。当然这个过程可能是缓慢的,这里使用Thread.sleep()模拟它:

01 public class FutureMain { 02 public static void main(String[] args) throws InterruptedException, ExecutionException { 03 //构造FutureTask 04 FutureTask<String> future = new FutureTask<String>(new RealData("a")); 05 ExecutorService executor = Executors.newFixedThreadPool(1); 06 //执行FutureTask,相当于上例中的 client.request("a") 发送请求 07 //在这里开启线程进行RealData的call()执行 08 executor.submit(future); 09 10 System.out.println("请求完毕"); 11 try { 12 //这里依然可以做额外的数据操作,这里使用sleep代替其他业务逻辑的处理 13 Thread.sleep(2000); 14 } catch (InterruptedException e) { 15 } 16 //相当于5.5.2节中得data.getResult (),取得call()方法的返回值 17 //如果此时call()方法没有执行完成,则依然会等待 18 System.out.println("数据 = " + future.get()); 19 } 20 }

上述代码就是使用Future模式的典型。第4行,构造了FutureTask对象实例,表示这个任务是有返回值的。构造FutureTask时,使用Callable接口,告诉FutureTask我们需要的数据应该如何产生。接着再第8行,将FutureTask提交给线程池。显然,作为一个简单的任务提交,这里必然是立即返回的,因此程序不会阻塞。接下来,我们不用关心数据是如何产生的。可以去做一些额外的事情,然后在需要的时候可以通过Future.get()(第18行)得到实际的数据。

除了基本的功能外,JDK还为Future接口提供了一些简单的控制功能:

boolean cancel(boolean mayInterruptIfRunning); //取消任务 boolean isCancelled(); //是否已经取消 boolean isDone(); //是否已完成 V get() throws InterruptedException, ExecutionException; //取得返回对象 V get(long timeout, TimeUnit unit) //取得返回对象,可以设置超时时间

5.6 并行流水线

并发算法虽然可以充分发挥多核CPU的性能。但不幸的是,并非所有的计算都可以改造成并发的形式。那什么样的算法是无法使用并发进行计算的呢?简单来说,执行过程中有数据相关性的运算都是无法完美并行化的。

假如现在有两个数,B和C。如果我们要计算(B+C)*B/2,那么这个运行过程就是无法并行的。原因是,如果B+C没有执行完成,则永远算不出(B+C)*B,这就是数据相关性。如果线程执行时,所需的数据存在这种依赖关系,那么,就没有办法将它们完美的并行化。如图5.10所示,诠释了这个道理。

图5.10 (B+C)*B/2无法并行化

那遇到这种情况时,有没有什么补救措施呢?答案是肯定的,那就是借鉴日常生产中的流水线思想。

比如,现在要生产一批小玩偶。小玩偶的制作分为四个步骤,第一要组装身体,第二要在身体上安装四肢和头部,第三,给组装完成的玩偶穿上一件漂亮的衣服,第四,就可以包装出货了。为了加快制作玩具的进度,我们不可能叫四个人同时加工一个玩具,因为这四个步骤有着严重的依赖关系。如果没有身体,就没有地方安装四肢,如果没有组装完成,就不能穿衣服,如果没有穿上衣服,就不能包装发货。因此,找四个人来做一个玩偶是毫无意义的。

但是,如果你现在要制作的不是1只玩偶,而是1万只玩偶,那情况就不同了。你可以找四个人,第一个人只负责组装身体,完成后交给第二个人;第二个人只负责安装头部和四肢,交付第三人;第三人只负责穿衣服,并交付第四人;第四人只负责包装发货。这样所有人都可以一起工作,共同完成任务,而整个时间周期也能缩短到原来的1/4左右,这就是流水线的思想。一旦流水线满载,每次只需要一步(假设一个玩偶需要四步)就可以产生一个玩偶,如图5.11所示。

图5.11 使用流水线生产玩偶

类似的思想可以借鉴到程序开发中。即使(B+C)*B/2无法并行,但是如果你需要计算一大堆B和C的值,你依然可以将它流水化。首先将计算过程拆分为三个步骤:

P1:A=B+C

P2:D=A×B

P3:D=D/2

上述步骤中P1、P2和P3均在单独的线程中计算,并且每个线程只负责自己的工作。此时,P3的计算结果就是最终需要的答案。

P1接收B和C的值,并求和,将结果输入给P2。P2求乘积后输入给P3。P3将D除以2得到最终值。一旦这条流水线建立,只需要一个计算步骤就可以得到(B+C)*B/2的结果。

为了实现这个功能,我们需要定义一个在线程间携带结果进行信息交换的载体:

public class Msg { public double i; public double j; public String orgStr=null; }

P1计算的是加法:

01 public class Plus implements Runnable { 02 public static BlockingQueue<Msg> bq=new LinkedBlockingQueue<Msg>(); 03 @Override 04 public void run() { 05 while(true){ 06 try { 07 Msg msg=bq.take(); 08 msg.j=msg.i+msg.j; 09 Multiply.bq.add(msg); 10 } catch (InterruptedException e) { 11 } 12 } 13 } 14 }

上述代码中,P1取得封装了两个操作数的Msg,并进行求和,将结果传递给乘法线程P2(第9行)。当没有数据需要处理时,P1进行等待。

P2计算乘法:

01 public class Multiply implements Runnable { 02 public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>(); 03 04 @Override 05 public void run() { 06 while (true) { 07 try { 08 Msg msg = bq.take(); 09 msg.i = msg.i * msg.j; 10 Div.bq.add(msg); 11 } catch (InterruptedException e) { 12 } 13 } 14 } 15 }

和P1非常类似,P2计算相乘结果后,将中间结果传递给除法线程P3。

P3计算除法:

01 public class Div implements Runnable { 02 public static BlockingQueue<Msg> bq = new LinkedBlockingQueue<Msg>(); 03 04 @Override 05 public void run() { 06 while (true) { 07 try { 08 Msg msg = bq.take(); 09 msg.i = msg.i / 2; 10 System.out.println(msg.orgStr + "=" + msg.i); 11 } catch (InterruptedException e) { 12 } 13 } 14 } 15 }

P3将结果除以2后输出最终的结果。

最后是提交任务的主线程,这里,我们提交100万个请求,让线程组进行计算:

01 public class PStreamMain { 02 public static void main(String[] args) { 03 new Thread(new Plus()).start(); 04 new Thread(new Multiply()).start(); 05 new Thread(new Div()).start(); 06 07 for (int i = 1; i <= 1000; i++) { 08 for (int j = 1; j <= 1000; j++) { 09 Msg msg = new Msg(); 10 msg.i = i; 11 msg.j = j; 12 msg.orgStr = "((" + i + "+" + j + ")*" + i + ")/2"; 13 Plus.bq.add(msg); 14 } 15 } 16 } 17 }

上述代码第13行,将数据提交给P1加法线程,开启流水线的计算。在多核或者分布式场景中,这种设计思路可以有效地将有依赖关系的操作分配在不同的线程中进行计算,尽可能利用多核优势。

5.7 并行搜索

搜索是几乎每一个软件都必不可少的功能。对于有序数据,通常可以采用二分查找法。对于无序数据,则只能挨个查找。在本节中,我们将讨论有关并行的无序数组的搜索实现。

给定一个数组,我们要查找满足条件的元素。对于串行程序来说,只要遍历一下数组就可以得到结果。但如果要使用并行方式,则需要额外增加一些线程间的通信机制,使各个线程可以有效地运行。

一种简单的策略就是将原始数据集合按照期望的线程数进行分割。如果我们计划使用两个线程进行搜索,那么就可以把一个数组或集合分割成两个。每个线程各自独立搜索,当其中有一个线程找到数据后,立即返回结果即可。

现在假设有一个整型数组,我们需要查找数组内的元素:

static int[] arr;

定义线程池、线程数量以及存放结果的变量result。在result中,我们会保存符合条件的元素在arr数组中的下标。默认为-1,表示没有找到给定元素。

static ExecutorService pool = Executors.newCachedThreadPool(); static final int Thread_Num=2; static AtomicInteger result=new AtomicInteger(-1);

并发搜索会要求每个线程查找arr中的一段,因此,搜素函数必须指定线程需要搜索的起始和结束位置:

01 public static int search(int searchValue,int beginPos,int endPos){ 02 int i=0; 03 for(i=beginPos;i<endPos;i++){ 04 if(result.get()>=0){ 05 return result.get(); 06 } 07 if(arr[i] == searchValue){ 08 //如果设置失败,表示其他线程已经先找到了 09 if(!result.compareAndSet(-1, i)){ 10 return result.get(); 11 } 12 return i; 13 } 14 } 15 return -1; 16 }

上述代码第4行,首先通过result判断是否已经有其他线程找到了需要的结果。如果已经找到,则立即返回不再进行查找。如果没有找到,则进行下一步搜索。第7行代码成立则表示当前线程找到了需要的数据,那么就会将结果保存到result变量中。这里使用CAS操作,如果设置失败,则表示其他线程已经先我一步找到了结果。因此,可以无视失败的情况,找到结果后,进行返回。

定义一个线程进行查找,它会调用前面的pSearch()方法:

01 public static class SearchTask implements Callable<Integer>{ 02 int begin,end,searchValue; 03 public SearchTask(int searchValue,int begin,int end){ 04 this.begin=begin; 05 this.end=end; 06 this.searchValue=searchValue; 07 } 08 public Integer call(){ 09 int re= search(searchValue,begin,end); 10 return re; 11 } 12 }

最后是pSearch()并行查找函数,它会根据线程数量对arr数组进行划分,并建立对应的任务提交给线程池处理:

01 public static int pSearch(int searchValue) throws InterruptedException, ExecutionException{ 02 int subArrSize=arr.length/Thread_Num+1; 03 List<Future<Integer>> re=new ArrayList<Future<Integer>>(); 04 for(int i=0;i<arr.length;i+=subArrSize){ 05 int end = i+subArrSize; 06 if(end>=arr.length)end=arr.length; 07 re.add(pool.submit(new SearchTask(searchValue,i,end))); 08 } 09 for(Future<Integer> fu:re){ 10 if(fu.get()>=0)return fu.get(); 11 } 12 return -1; 13 }

上述代码中使用了JDK内置的Future模式,其中第4~8行将原始数组arr划分为若干段,并根据划分结果建立子任务。每一个子任务都会返回一个Future对象,通过Future对象可以获得线程组得到的最终结果。在这里,由于线程之间通过result共享彼此的信息,因此只要当一个线程成功返回后,其他线程都会立即返回。因此,不会出现由于排在前面的任务长时间无法结束而导致整个搜索结果无法立即获取的情况。

5.8 并行排序

排序是一项非常常用的操作。你的应用程序在运行时,可能无时无刻不在进行排序操作。排序的算法有很多,但在这里我并不打算一一介绍它们。对于大部分排序算法来说,都是串行执行的。当排序元素很多时,若使用并行算法代替串行算法,显然可以更加有效地利用CPU。但将串行算法改造成并行算法并非易事,甚至会极大地增加原有算法的复杂度。在这里,我将介绍几种相对简单的,但是也足以让人脑洞大开的平行排序算法。

5.8.1 分离数据相关性:奇偶交换排序

在介绍奇偶排序前,首先让我们看一下熟悉的冒泡排序。在这里,假设我们需要将数组进行从小到大的排序。冒泡排序的操作很类似水中的起泡上浮,在冒泡排序的执行过程中,如果数据较小,它就会逐步被交换到前面去,相反,对于大的数字,则会下沉,交换到数组的末尾。

冒泡排序的一般算法如下:

01 public static void bubbleSort(int[] arr) { 02 for (int i = arr.length - 1; i > 0; i--) { 03 for (int j = 0; j < i; j++) { 04 if (arr[j] > arr[j + 1]) { 05 int temp = arr[j]; 06 arr[j] = arr[j + 1]; 07 arr[j + 1] = temp; 08 } 09 } 10 } 11 }

如图5.12所示,展示了冒泡排序的几次迭代过程:

图5.12 冒泡排序迭代过程

大家可以看到,在每次迭代的交换过程中,由于每次交换的两个元素存在数据冲突,对于每个元素,它既可能与前面的元素交换,也可能和后面的元素交换,因此很难直接改造成并行算法。

如果能够解开这种数据的相关性,就可以比较容易地使用并行算法来实现类似的排序。奇偶交换排序就是基于这种思想的。

对于奇偶交换排序来说,它将排序过程分为两个阶段,奇交换和偶交换。对于奇交换来说,它总是比较奇数索引以及其相邻的后续元素。而偶交换总是比较偶数索引和其相邻的后续元素。并且,奇交换和偶交换会成对出现,这样才能保证比较和交换涉及到数组中的每一个元素。

奇偶交换的迭代示意图如图5.13所示。

图5.13 奇偶交换迭代示意图

可以看到,由于将整个比较交换独立分割为奇阶段和偶阶段。这就使得在每一个阶段内,所有的比较和交换是没有数据相关性的。因此,每一次比较和交换都可以独立执行,也就可以并行化了。

下面是奇偶交换排序的串行实现:

01 public static void oddEvenSort(int[] arr) { 02 int exchFlag = 1, start = 0; 03 while (exchFlag == 1 || start == 1) { 04 exchFlag = 0; 05 for (int i = start; i < arr.length - 1; i += 2) { 06 if (arr[i] > arr[i + 1]) { 07 int temp = arr[i]; 08 arr[i] = arr[i + 1]; 09 arr[i + 1] = temp; 10 exchFlag = 1; 11 } 12 } 13 if (start == 0) 14 start = 1; 15 else 16 start = 0; 17 } 18 }

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