Oracle官方并发教程(1)

来源:互联网 发布:算法导论第二版百度云 编辑:程序博客网 时间:2024/05/16 12:23

计算机的使用者一直以为他们的计算机可以同时做很多事情。他们认为当其他的应用程序在下载文件,管理打印队列或者缓冲音频的时候他们可以继续在文字处理程序上工作。甚至对于单个应用程序,他们任然期待它能在在同一时间做很多事情。举个例子,一个流媒体播放程序必须能同时完成以下工作:从网络上读取数字音频,解压缩数字音频,管理播放和更新程序显示。甚至文字处理器也应该能在忙于重新格式化文本和刷新显示的情况下同时响应键盘和鼠标事件。这样的软件就被称为并发软件。

通过Java语言和Java类库对于基础并发的支持,JAVA平台具有完全(from the ground up )支持并发编程的能力。从JDK5.0起,Java平台还引入了高级并发APIs。这个课程不仅涵盖了Java平台基础并发内容,还对高级并发APIs有一定的阐述。


进程和线程


在并发编程中,有两个基本的执行单元:进程和线程。在java语言中,并发编程最关心的是线程,然而,进程也是非常重要的。

即使在只有单一的执行核心的计算机系统中,也有许多活动的进程和线程。因此,在任何给定的时刻,只有一个线程在实际执行。处理器的处理时间是通过操作系统的时间片在进程和线程中共享的。
现在具有多处理器或有多个执行内核的多处理器的计算机系统越来越普遍,这大大增强了系统并发执行的进程和线程的吞吐量–但在不没有多个处理器或执行内核的简单的系统中,并发任然是可能的。

进程

进程具有一个独立的执行环境。通常情况下,进程拥有一个完整的、私有的基本运行资源集合。特别地,每个进程都有自己的内存空间。
进程往往被看作是程序或应用的代名词,然而,用户看到的一个单独的应用程序实际上可能是一组相互协作的进程集合。为了便于进程之间的通信,大多数操作系统都支持进程间通信(IPC),如pipes 和sockets。IPC不仅支持同一系统上的通信,也支持不同的系统。
Java虚拟机的大多数实现是单进程的。Java应用可以使用的ProcessBuilder对象创建额外的进程,多进程应用超出了本课的范围。

线程

线程有时也被称为轻量级的进程。进程和线程都提供了一个执行环境,但创建一个新的线程比创建一个新的进程需要的资源要少。
线程是在进程中存在的,每个进程最少有一个线程。线程共享进程的资源,包括内存和打开的文件。这样提高了效率,但潜在的问题就是线程间的通信。
多线程的执行是Java平台的一个基本特征。每个应用都至少有一个线程,或几个,如果算上“系统”线程的话,比如内存管理和信号处理等。但是从程序员的角度来看,启动的只有一个线程,叫主线程。这个线程有能力创建额外的线程,我们将在下一节演示。


线程对象


在Java中,每个线程都是Thread类的实例。并发应用中一般有两种不同的线程创建策略。

  • 直接控制线程的创建和管理,每当应用程序需要执行一个异步任务的时候就为其创建一个线程
  • 将线程的管理从应用程序中抽象出来作为执行器,应用程序将任务传递给执行器,由执行器负责执行。

这一节,我们将讨论Thread对象,有关Executors将在高级并发对象一节中讨论。

定义并启动一个线程

应用程序在创建一个线程实例时,必须提供需要在线程中运行的代码。有两种方式去做到这一点:

  • 提供一个Runnable对象。Runnable对象仅包含一个run()方法,在这个方法中定义的代码将在会线程中执行。将Runnable对象传递给Thread类的构造函数即可,如下面这个HelloRunnable的例子:
public class HelloRunnable implements Runnable {    public void run() {        System.out.println("Hello from a thread!");    }    public static void main(String args[]) {        (new Thread(new HelloRunnable())).start();    }}

  • 继承Thread类。Thread类自身已实现了Runnable接口,但它的run()方法中并没有定义任何代码。应用程序可以继承与Thread类,并复写run()方法。如例子HelloThread ,代码如下:
public class HelloThread extends Thread {    public void run() {        System.out.println("Hello from a thread!");    }    public static void main(String args[]) {        (new HelloThread()).start();    }}
需要注意的是,上述两个例子都需要调用Thread.start()方法来启动一个新的线程。 哪一种方式是我们应该使用的?相对来说,第一种更加通用,因为Runnable对象可以继承于其他类(Java只支持单继承,当一个类继承与Thread类后,就无法继承与其他类)。第二种方法更易于在简单的应用程序中使用,但它的局限就是:你的任务类必须是Thread的子类。这个课程更加聚焦于第一种将Runnable任务和Thread类分离的方式。不仅仅是因为这种方式更加灵活,更因为它更适合后面将要介绍的高级线程管理API。 Thread类定义了一些对线程管理十分有用的的方法。在这些方法中,有一些静态方法可以给当前线程调用,它们可以提供一些有关线程的信息,或者影响线程的状态。而其他一些方法可以由其他线程进行调用,用于管理线程和Thread对象。我们将在下面的章节中,深入探讨这些内容。

使用Sleep方法暂停一个线程

使用Thread.sleep()方法可以暂停当前线程一段时间。这是一种使处理器时间可以被其他线程或者运用程序使用的有效方式。sleep()方法还可以用于调整线程执行节奏(见下面的例子)和等待其他有执行时间需求的线程(这个例子将在下一节演示)。

在Thread中有两个不同的sleep()方法,一个使用毫秒表示休眠的时间,而另一个是用纳秒。由于操作系统的限制休眠时间并不能保证十分精确。休眠周期可以被interrups所终止,我们将在后面看到这样的例子。不管在任何情况下,我们都不应该假定调用了sleep()方法就可以将一个线程暂停一个十分精确的时间周期。

SleepMessages程序为我们展示了使用sleep()方法每四秒打印一个信息的例子:

public class SleepMessages {    public static void main(String args[])        throws InterruptedException {        String importantInfo[] = {            "Mares eat oats",            "Does eat oats",            "Little lambs eat ivy",            "A kid will eat ivy too"        };        for (int i = 0; i < importantInfo.length; i++) {            //Pause for 4 seconds            Thread.sleep(4000);            //Print a message            System.out.println(importantInfo[i]);        }    }}

main()方法声明了它有可能抛出InterruptedException。当其他线程中断当前线程时,sleep()方法就会抛出该异常。由于这个应用程序并没有定义其他的线程,所以并不用关心如何处理该异常。

中断(Interrupts)

中断是给线程的一个指示,告诉它应该停止正在做的事并去做其他事情。一个线程究竟要怎么响应中断请求取决于程序员,不过让其终止是很普遍的做法。这是本文重点强调的用法。

一个线程通过调用对被中断线程的Thread对象的interrupt()方法,发送中断信号。为了让中断机制正常工作,被中断的线程必须支持它自己的中断(即要自己处理中断)

中断支持

线程如何支持自身的中断?这取决于它当前正在做什么。如果线程正在频繁调用会抛InterruptedException异常的方法,在捕获异常之后,它只是从run()方法中返回。例如,假设在SleepMessages的例子中,关键的消息循环在线程的Runnable对象的run方法中,代码可能会被修改成下面这样以支持中断:

for (int i = 0; i < importantInfo.length; i++) {    // Pause for 4 seconds    try {        Thread.sleep(4000);    } catch (InterruptedException e) {        // We've been interrupted: no more messages.        return;    }    // Print a message    System.out.println(importantInfo[i]);}

许多会抛InterruptedException异常的方法(如sleep()),被设计成接收到中断后取消它们当前的操作,并在立即返回。

如果一个线程长时间运行而不调用会抛InterruptedException异常的方法会怎样? 那它必须周期性地调用Thread.interrupted()方法,该方法在接收到中断请求后返回true。例如:

for (int i = 0; i < inputs.length; i++) {    heavyCrunch(inputs[i]);    if (Thread.interrupted()) {        // We've been interrupted: no more crunching.        return;    }}

在这个简单的例子中,代码只是检测中断,并在收到中断后退出线程。在更复杂的应用中,抛出一个InterruptedException异常可能更有意义。

if (Thread.interrupted()) {    throw new InterruptedException();}

这使得中断处理代码能集中在catch语句中。

中断状态标记

中断机制通过使用称为中断状态的内部标记来实现。调用Thread.interrupt()设置这个标记。当线程通过调用静态方法Thread.interrupted()检测中断时,中断状态会被清除。非静态的isInterrupted()方法被线程用来检测其他线程的中断状态,不改变中断状态标记。

按照惯例,任何通过抛出一个InterruptedException异常退出的方法,当抛该异常时会清除中断状态。不过,通过其他的线程调用interrupt()方法,中断状态总是有可能会立即被重新设置。

Joins

Join()方法可以让一个线程等待另一个线程执行完成。若t是一个正在执行的Thread对象,

t.join();

将会使当前线程暂停执行并等待t执行完成。重载的join()方法可以让开发者自定义等待周期。然而,和sleep()方法一样join()方法依赖于操作系统的时间处理机制,你不能假定join()方法将会精确的等待你所定义的时长。

如同sleep()方法,join()方法响应中断并在中断时抛出InterruptedException。

一个简单的线程例子

下面这个简单的例子将会把这一节的一些概念放到一起演示。SimpleThreads程序有两个线程组成,第一个是主线程,它从创建了一个线程并等待它执行完成。如果MessageLoop线程执行了太长时间,主线程将会将其中断。

MessageLoop现场将会打印一系列的信息。如果中断在它打印完所有信息前发生,它将会打印一个特定的消息并退出。

public class SimpleThreads {    // 当前线程,显示一条消息    static void threadMessage(String message) {        String threadName = Thread.currentThread().getName();        System.out.format("%s: %s%n", threadName, message);    }    //MessageLoop子线程    private static class MessageLoop implements Runnable {        @Override        public void run() {            String importantInfo[] = {                "Mares eat oats",                "Does eat oats",                "Little lambs eat ivy",                "A kid will eat ivy too"            };            try {                for (int i = 0; i < importantInfo.length; i++) {                    // 暂停4秒                    Thread.sleep(4000);                    // 打印一条消息                    threadMessage(importantInfo[i]);                }            } catch (InterruptedException e) {  //若线程收到中断                threadMessage("I wasn't done!");            }        }    }    public static void main(String args[]) throws InterruptedException {        // 在中断MessageLoop线程之前的时延(默认为1小时)        long patience = 1000 * 60 * 60;        // 如果命令行参数指定了秒数时延        if (args.length > 0) {            try {                patience = Long.parseLong(args[0]) * 1000;            } catch (NumberFormatException e) {                System.err.println("Argument must be an integer.");                System.exit(1);            }        }        threadMessage("Starting MessageLoop thread");  //主线程打印一条消息        long startTime = System.currentTimeMillis();        Thread t = new Thread(new MessageLoop());        t.start();  //启动MessageLoop子线程        threadMessage("Waiting for MessageLoop thread to finish");        // 循环直到MessageLoop线程退出        while (t.isAlive()) {            threadMessage("Still waiting...");            // 主线程等待1秒,以让MessageLoop线程执行            t.join(1000);            //若主线程执行达到给定时延,中断MessageLoop线程            if (((System.currentTimeMillis() - startTime) > patience)                    && t.isAlive()) {                threadMessage("Tired of waiting!");                t.interrupt();                // Shouldn't be long now                // -- wait indefinitely                t.join();            }        }        threadMessage("Finally!");    }}


同步


线程间的通信主要是通过共享域和引用相同的对象。这种通信方式非常高效,不过可能会引发两种错误:线程干扰和内存一致性错误。防止这些错误发生的方法是同步。

不过,同步会引起线程竞争,当两个或多个线程试图同时访问相同的资源,随之就导致Java运行时环境执行其中一个或多个线程比原先慢很多,甚至执行被挂起,这就出现了线程竞争。线程饥饿和活锁都属于线程竞争的范畴。关于线程竞争的更多信息可参考活跃度一节。

本节内容包括以下这些主题:

  • 线程干扰讨论了当多个线程访问共享数据时错误是怎么发生的。
  • 内存一致性错误讨论了不一致的共享内存视图导致的错误。
  • 同步方法讨论了 一种能有效防止线程干扰和内存一致性错误的常见做法。
  • 内部锁和同步讨论了更通用的同步方法,以及同步是如何基于内部锁实现的。
  • 原子访问讨论了不能被其他线程干扰的操作的总体思路。

线程干扰

下面这个简单的Counter类:

class Counter {    private int c = 0;    public void increment() {        c++;    }    public void decrement() {        c--;    }    public int value() {        return c;    }}

Counter类被设计成:每次调用increment()方法,c的值加1;每次调用decrement()方法,c的值减1。如果当同一个Counter对象被多个线程引用,线程间的干扰可能会使结果同我们预期的不一致。

当两个运行在不同的线程中却作用在相同的数据上的操作交替执行时,就发生了线程干扰。这意味着这两个操作都由多个步骤组成,而步骤间的顺序产生了重叠。

Counter类实例的操作会交替执行,这看起来似乎不太可能,因为c上的这两个操作都是单一而简单的语句。然而,即使一个简单的语句也会被虚拟机转换成多个步骤。我们不去深究虚拟机内部的详细执行步骤——理解c++这个单一的语句会被分解成3个步骤就足够了:

  1. 获取当前c的值;
  2. 对获取到的值加1;
  3. 把递增后的值写回到c;

语句c–也可以按同样的方式分解,除了第二步的操作是递减而不是递增。

假设线程A调用increment()的同时线程B调用decrement().如果c的初始值为0,线程A和B之间的交替执行顺序可能是下面这样:

线程A:获取c;
线程B:获取c;
线程A:对获取的值加1,结果为1;
线程B:对获取的值减1,结果为-1;
线程A:结果写回到c,c现在是1;
线程B:结果写回到c,c现在是-1;

线程A的结果因为被线程B覆盖而丢失了。这个交替执行的结果只是其中一种可能性。在不同的环境下,可能是线程B的结果丢失了,也可能是不会出任何问题。由于结果是不可预知的,所以线程干扰的bug很难检测和修复。

内存一致性错误

当不同的线程对相同的数据产生不一致的视图时会发生内存一致性错误。内存一致性错误的原因比较复杂,也超出了本教程的范围。不过幸运的是,一个程序员并不需要对这些原因有详细的了解。所需要的是避免它们的策略。
避免内存一致性错误的关键是理解happens-before关系。这种关系只是确保一个特定语句的写内存操作对另外一个特定的语句可见。要说明这个问题,请参考下面的例子。假设定义和初始化了一个简单int字段:

int counter = 0;
这个counter字段被A,B两个线程共享。假设线程A对counter执行递增:
counter++;
然后,很快的,线程B输出counter:
System.out.println(counter);

如果这两个语句已经在同一个线程中被执行过,那么输出的值应该是“1”。不过如果这两个语句在不同的线程中分开执行,那输出的值很可能是“0”,因为无法保证线程A对counter的改动对线程B是可见的——除非我们在这两个语句之间已经建立了happens-before关系。

有许多操作会建立happens-before关系。其中一个是同步,我们将在下面的章节中看到。

我们已经见过两个建立happens-before关系的操作。

  • 当一条语句调用Thread.start方法时,和该语句有happens-before关系的每一条语句,跟新线程执行的每一条语句同样有happens-before关系。创建新线程之前的代码的执行结果对线新线程是可见的。
  • 当一个线程终止并且当导致另一个线程中的Thread.join返回时,被终止的线程执行的所有语句和在join返回成功之后的所有语句间有happens-before关系。线程中代码的执行结果对执行join操作的线程是可见的。

要查看建立happens-before关系的操作列表,请参阅java.util.concurrent包的摘要页面。

同步方法

Java编程语言提供两种同步方式:同步方法和同步语句。相对较复杂的同步语句将在下一节中介绍。本节主要关注同步方法。

要让一个方法成为同步方法,只需要在方法声明中加上synchronized关键字:

public class SynchronizedCounter {    private int c = 0;    public synchronized void increment() {        c++;    }    public synchronized void decrement() {        c--;    }    public synchronized int value() {        return c;    }}
如果countSynchronizedCounter类的实例,那么让这些方法成为同步方法有两个作用:
  • 首先,相同对象上的同步方法的两次调用,它们要交替执行是不可能的。 当一个线程正在执行对象的同步方法时,所有其他调用该对象同步方法的线程会被阻塞(挂起执行),直到第一个线程处理完该对象。
  • 其次,当一个同步方法退出时,它会自动跟该对象同步方法的任意后续调用建立起一种happens-before关系。这确保对象状态的改变对所有线程是可见的。

注意构造方法不能是同步的——构造方法加synchronized关键字会报语法错误。同步的构造方法没有意义,因为当这个对象被创建的时候,只有创建对象的线程能访问它。

警告:当创建的对象会被多个线程共享时必须非常小心,对象的引用不要过早“暴露”出去。比如,假设你要维护一个叫instancesList,它包含类的每一个实例对象。你可能会尝试在构造方法中加这样一行:

instances.add(this);

不过其他线程就能够在对象构造完成之前使用instances访问对象。

同步(synchronized)方法使用一种简单的策略来防止线程干扰和内存一致性错误:如果一个对象对多个线程可见,对象域上的所有读写操作都是通过synchronized方法来完成的。(一个重要的例外:final域,在对象被创建后不可修改,能被非synchronized方法安全的读取)。synchronized同步策略很有效,不过会引起活跃度问题,我们将在本节后面看到。

内部锁与同步

同步机制的建立是基于其内部一个叫内部锁或者监视锁的实体。(在Java API规范中通常被称为监视器。)内部锁在同步机制中起到两方面的作用:对一个对象的排他性访问;建立一种happens-before关系,而这种关系正是可见性问题的关键所在。

每个对象都有一个与之关联的内部锁。通常当一个线程需要排他性的访问一个对象的域时,首先需要请求该对象的内部锁,当访问结束时释放内部锁。在线程获得内部锁到释放内部锁的这段时间里,我们说线程拥有这个内部锁。那么当一个线程拥有一个内部锁时,其他线程将无法获得该内部锁。其他线程如果去尝试获得该内部锁,则会被阻塞。

当线程释放一个内部锁时,该操作和对该锁的后续请求间将建立happens-before关系。

同步方法中的锁

当线程调用一个同步方法时,它会自动请求该方法所在对象的内部锁。当方法返回结束时则自动释放该内部锁,即使退出是由于发生了未捕获的异常,内部锁也会被释放。

你可能会问调用一个静态的同步方法会如何,由于静态方法是和类(而不是对象)相关的,所以线程会请求类对象(Class Object)的内部锁。因此用来控制类的静态域访问的锁不同于控制对象访问的锁。

同步块

另外一种同步的方法是使用同步块。和同步方法不同,同步块必须指定所请求的是哪个对象的内部锁:

public void addName(String name) {    synchronized(this) {        lastName = name;        nameCount++;    }    nameList.add(name);}
在上面的例子中,addName方法需要使lastName和nameCount的更改保持同步,而且要避免同步调用该对象的其他方法。(在同步代码中调用其他方法会产生Liveness一节所描述的问题。)如果不使用同步块,那么必须要定义一个额外的非同步方法,而这个方法仅仅是用来调用nameList.add。
使用同步块对于更细粒度的同步很有帮助。例如类MsLunch有两个实例域c1和c2,他们并不会同时使用(译者注:即c1和c2是彼此无关的两个域),所有对这两个域的更新都需要同步,但是完全不需要防止c1的修改和c2的修改相互之间干扰(这样做只会产生不必要的阻塞而降低了并发性)。这种情况下不必使用同步方法,可以使用和this对象相关的锁。这里我们创建了两个“锁”对象(译者注:起到加锁效果的普通对象lock1和lock2)。
public class MsLunch {    private long c1 = 0;    private long c2 = 0;    private Object lock1 = new Object();    private Object lock2 = new Object();    public void inc1() {        synchronized(lock1) {            c1++;        }    }    public void inc2() {        synchronized(lock2) {            c2++;        }    }}

使用这种方法时要特别小心,需要十分确定c1和c2是彼此无关的域。

可重入同步

还记得吗,一个线程不能获得其他线程所拥有的锁。但是它可以获得自己已经拥有的锁。允许一个线程多次获得同一个锁实现了可重入同步。这里描述了一种同步代码的场景,直接的或间接地,调用了一个也拥有同步代码的方法,且两边的代码使用的是同一把锁。如果没有这种可重入的同步机制,同步代码则需要采取许多额外的预防措施以防止线程阻塞自己。

原子访问

在编程过程中,原子操作是指所有操作都同时发生。原子操作不能被中途打断:要么全做,要么不做。原子操作在完成前不会有看得见的副作用。

我们发现像c++这样的增量表达式,并没有描述原子操作。即使是非常简单的表达式也能够定义成能被分解为其他操作的复杂操作。然而,有些操作你可以定义为原子的:

  • 对引用变量和大部分基本类型变量(除long和double之外)的读写是原子的。
  • 对所有声明为volatile的变量(包括long和double变量)的读写是原子的。

原子操作不会交错,于是可以放心使用,不必担心线程干扰。然而,这并不能完全消除原子操作上的同步,因为内存一致性错误仍可能发生。使用volatile变量可以降低内存一致性错误的风险,因为对volatile变量的任意写操作,对于后续在该变量上的读操作建立了happens-before关系。这意味着volatile变量的修改对于其他线程总是可见的。更重要的是,这同时也意味着当一个线程读取一个volatile变量时,它不仅能看到该变量最新的修改,而且也能看到致使该改变发生的代码的副作用。

使用简单的原子变量访问比通过同步代码来访问更高效,但是需要程序员更加谨慎以避免内存一致性错误。至于这额外的付出是否值得,得看应用的大小和复杂度。

java.util.concurrent包中的一些类提供了一些不依赖同步机制的原子方法。我们将在高级并发对象这一节中讨论它们。


活跃度


一个并发应用程序能及时执行的能力称为活跃性。本节将介绍最常见的活跃性问题:死锁(deadlock),以及另外两个活跃性问题:饥饿(starvation)和活锁(livelock)。

死锁

死锁描述了这样一种情景,两个或多个线程永久阻塞,互相等待对方释放资源。下面是一个例子。

Alphone和Gaston是朋友,都很讲究礼节。礼节有一个严格的规矩,当你向一个朋友鞠躬时,你必须保持鞠躬的姿势,直到你的朋友有机会回鞠给你。不幸的是,这个规矩没有算上两个朋友相互同时鞠躬的可能。

下面的应用例子,Deadlock,模拟了这个可能性。

public class Deadlock {    static class Friend {        private final String name;        public Friend(String name) {            this.name = name;        }        public String getName() {            return this.name;        }        public synchronized void bow(Friend bower) {            System.out.format("%s: %s" + "  has bowed to me!%n", this.name, bower.getName());            bower.bowBack(this);        }        public synchronized void bowBack(Friend bower) {            System.out.format("%s: %s" + " has bowed back to me!%n", this.name, bower.getName());        }    }    public static void main(String[] args) {        final Friend alphonse = new Friend("Alphonse");        final Friend gaston = new Friend("Gaston");        new Thread(new Runnable() {            @Override            public void run() {                //先锁住alphonse,然后在bow()里面调用bowBack()时需要锁住gaston                alphonse.bow(gaston);            }        }).start();        new Thread(new Runnable() {            @Override            public void run() {                //若在上面线程锁住gaston之前,本线程进入bow()锁住gaston,                //在bow()里面需要锁住alphonse,但它已经在上面的线程被锁住                //造成死锁                gaston.bow(alphonse);            }        }).start();    }}

当Deadlock运行后,两个线程极有可能阻塞,当它们尝试调用bowBack方法时。没有哪个阻塞会结束,因为每个线程都在等待另一个线程退出bow方法。

饥饿和活锁

饥饿和活锁并不如死锁一般普遍,但它仍然是每个并发程序设计者可能会遇到的问题。

饥饿

饥饿是指一个线程不能正常的访问共享资源并且不能正常执行的情况。这通常在共享资源被其他“贪心”的线程长期时发生。举个例子,假设一个对象提供了一个同步方法,这个方法通常需要执行很长一段时间才返回。如果一个线程经常调用这个方法,那么其他需要同步的访问这个对象的线程就经常会被阻塞。

活锁

一个线程通常会有会响应其他线程的活动。如果其他线程也会响应这个线程的活动,那么就有可能发生活锁。同死锁一样,发生活锁的线程无法继续执行。然而线程并没有阻塞——他们在忙于响应对方无法恢复工作。这就相当于两个在走廊相遇的人:Alphonse向他自己的左边靠想让Gaston过去,而Gaston向他的右边靠想让Alphonse过去。可见他们阻塞了对方。Alphonse向他的右边靠,而Gaston向他的左边靠,他们还是阻塞了对方。


英文原文:http://docs.oracle.com/javase/tutorial/essential/concurrency/index.html

中文参考:http://ifeve.com/oracle-java-concurrency-tutorial/

0 0
原创粉丝点击