Java 中文官方教程 2022 版(十)(1)

简介: Java 中文官方教程 2022 版(十)

Executors

原文:docs.oracle.com/javase/tutorial/essential/concurrency/executors.html

在所有先前的示例中,新线程执行的任务与其Runnable对象定义的线程本身(由Thread对象定义)之间存在密切联系。这对于小型应用程序效果很好,但在大型应用程序中,将线程管理和创建与应用程序的其余部分分离是有意义的。封装这些功能的对象称为executors。以下小节详细描述了 executors。

  • Executor Interfaces 定义了三种 executor 对象类型。
  • Thread Pools 是最常见的 executor 实现类型。
  • Fork/Join 是一个利用多处理器的框架(JDK 7 中新增)。

执行器接口

原文:docs.oracle.com/javase/tutorial/essential/concurrency/exinter.html

java.util.concurrent 包定义了三个执行器接口:

  • Executor 是一个简单的接口,支持启动新任务。
  • ExecutorServiceExecutor 的子接口,增加了一些功能,有助于管理单个任务和执行器本身的生命周期。
  • ScheduledExecutorServiceExecutorService 的子接口,支持未来和/或定期执行任务。

通常,引用执行器对象的变量声明为这三种接口类型之一,而不是执行器类类型。

Executor 接口

Executor 接口提供了一个方法 execute,旨在成为常见线程创建习语的替代品。如果 r 是一个 Runnable 对象,e 是一个 Executor 对象,你可以替换

(new Thread(r)).start();

with

e.execute(r);

然而,execute 的定义不太具体。低级习语创建一个新线程并立即启动它。根据 Executor 的实现,execute 可能会做同样的事情,但更有可能使用现有的工作线程来运行 r,或者将 r 放入队列等待工作线程可用。(我们将在线程池部分描述工作线程。)

java.util.concurrent 中的执行器实现旨在充分利用更高级的 ExecutorServiceScheduledExecutorService 接口,尽管它们也与基本的 Executor 接口一起工作。

ExecutorService 接口

ExecutorService 接口通过类似但更灵活的 submit 方法来补充 execute。与 execute 一样,submit 接受 Runnable 对象,但也接受 Callable 对象,允许任务返回一个值。submit 方法返回一个 Future 对象,用于检索 Callable 返回值并管理 CallableRunnable 任务的状态。

ExecutorService 还提供了提交大量 Callable 对象的方法。最后,ExecutorService 提供了一些方法来管理执行器的关闭。为了支持立即关闭,任务应正确处理中断。

ScheduledExecutorService 接口

ScheduledExecutorService 接口通过 schedule 补充了其父接口 ExecutorService 的方法,该方法在指定延迟后执行 RunnableCallable 任务。此外,该接口定义了 scheduleAtFixedRatescheduleWithFixedDelay,以在定义的间隔时间内重复执行指定任务。

线程池

原文:docs.oracle.com/javase/tutorial/essential/concurrency/pools.html

java.util.concurrent 中的大多数执行器实现使用线程池,其中包含工作线程。这种类型的线程与它执行的RunnableCallable任务分开存在,并经常用于执行多个任务。

使用工作线程可以最小化由于线程创建而产生的开销。线程对象使用大量内存,在大规模应用程序中,分配和释放许多线程对象会产生显著的内存管理开销。

一种常见的线程池类型是固定线程池。这种类型的池始终有指定数量的线程在运行;如果某个线程在仍在使用时被终止,它将自动被新线程替换。任务通过内部队列提交到池中,当活动任务多于线程时,队列会保存额外的任务。

使用固定线程池的一个重要优势是应用程序在使用它时优雅降级。要理解这一点,考虑一个 Web 服务器应用程序,其中每个 HTTP 请求都由一个单独的线程处理。如果应用程序只是为每个新的 HTTP 请求创建一个新线程,并且系统接收到的请求多于它立即处理的能力,当所有这些线程的开销超过系统容量时,应用程序将突然停止响应所有请求。通过限制可以创建的线程数量,应用程序将不会像请求进来那样快速地为 HTTP 请求提供服务,但它将以系统能够维持的速度为它们提供服务。

创建使用固定线程池的执行器的简单方法是在java.util.concurrent.Executors中调用newFixedThreadPool工厂方法。该类还提供以下工厂方法:

  • newCachedThreadPool方法创建一个具有可扩展线程池的执行器。此执行器适用于启动许多短暂任务的应用程序。
  • newSingleThreadExecutor方法创建一个一次执行一个任务的执行器。
  • 几个工厂方法是上述执行器的ScheduledExecutorService版本。

如果上述工厂方法提供的任何执行器都不符合您的需求,构造java.util.concurrent.ThreadPoolExecutorjava.util.concurrent.ScheduledThreadPoolExecutor的实例将为您提供额外的选项。

分叉/合并

原文:docs.oracle.com/javase/tutorial/essential/concurrency/forkjoin.html

分叉/合并框架是ExecutorService接口的一种实现,可以帮助你充分利用多个处理器。它专为可以递归地分解为较小片段的工作而设计。目标是利用所有可用的处理能力来提高应用程序的性能。

与任何ExecutorService实现一样,分叉/合并框架将任务分配给线程池中的工作线程。分叉/合并框架的独特之处在于它使用工作窃取算法。工作线程如果没有任务可执行,可以从其他仍在忙碌的线程中窃取任务。

分叉/合并框架的核心是ForkJoinPool类,它是AbstractExecutorService类的扩展。ForkJoinPool实现了核心的工作窃取算法,并可以执行ForkJoinTask进程。

基本用法

使用分叉/合并框架的第一步是编写执行一部分工作的代码。你的代码应该类似于以下伪代码:

if (my portion of the work is small enough)
  do the work directly
else
  split my work into two pieces
  invoke the two pieces and wait for the results

将这段代码封装在一个ForkJoinTask子类中,通常使用其中的一个更专门的类型,要么是RecursiveTask(可以返回结果),要么是RecursiveAction

当你的ForkJoinTask子类准备就绪后,创建代表所有要完成工作的对象,并将其传递给ForkJoinPool实例的invoke()方法。

清晰的模糊

为了帮助你理解分叉/合并框架的工作原理,请考虑以下示例。假设你想要对图像进行模糊处理。原始图像由一个整数数组表示,其中每个整数包含单个像素的颜色值。模糊后的目标图像也由一个与源图像大小相同的整数数组表示。

执行模糊操作是通过逐个像素地处理源数组来完成的。每个像素与其周围像素(红色、绿色和蓝色分量取平均值)进行平均,结果放入目标数组中。由于图像是一个大数组,这个过程可能需要很长时间。你可以利用多处理器系统上的并发处理,使用分叉/合并框架来实现算法。以下是一个可能的实现:

public class ForkBlur extends RecursiveAction {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;
    // Processing window size; should be odd.
    private int mBlurWidth = 15;
    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }
    protected void computeDirectly() {
        int sidePixels = (mBlurWidth - 1) / 2;
        for (int index = mStart; index < mStart + mLength; index++) {
            // Calculate average.
            float rt = 0, gt = 0, bt = 0;
            for (int mi = -sidePixels; mi <= sidePixels; mi++) {
                int mindex = Math.min(Math.max(mi + index, 0),
                                    mSource.length - 1);
                int pixel = mSource[mindex];
                rt += (float)((pixel & 0x00ff0000) >> 16)
                      / mBlurWidth;
                gt += (float)((pixel & 0x0000ff00) >>  8)
                      / mBlurWidth;
                bt += (float)((pixel & 0x000000ff) >>  0)
                      / mBlurWidth;
            }
            // Reassemble destination pixel.
            int dpixel = (0xff000000     ) |
                   (((int)rt) << 16) |
                   (((int)gt) <<  8) |
                   (((int)bt) <<  0);
            mDestination[index] = dpixel;
        }
    }
  ...

现在你要实现抽象的compute()方法,该方法可以直接执行模糊操作,也可以将其拆分为两个较小的任务。一个简单的数组长度阈值有助于确定是执行工作还是拆分任务。

protected static int sThreshold = 100000;
protected void compute() {
    if (mLength < sThreshold) {
        computeDirectly();
        return;
    }
    int split = mLength / 2;
    invokeAll(new ForkBlur(mSource, mStart, split, mDestination),
              new ForkBlur(mSource, mStart + split, mLength - split,
                           mDestination));
}

如果前面的方法在RecursiveAction类的子类中,那么设置任务在ForkJoinPool中运行就很简单,包括以下步骤:

  1. 创建一个代表所有要完成工作的任务。
// source image pixels are in src
// destination image pixels are in dst
ForkBlur fb = new ForkBlur(src, 0, src.length, dst);
  1. 创建将运行任务的ForkJoinPool
ForkJoinPool pool = new ForkJoinPool();
  1. 运行任务。
pool.invoke(fb);

要查看完整的源代码,包括一些额外的代码来创建目标图像文件,请参见ForkBlur示例。

标准实现

除了在多处理器系统上并行执行任务的自定义算法(例如前一节中的ForkBlur.java示例)中使用分支/合并框架之外,Java SE 中还有一些通用功能已经使用分支/合并框架实现。其中一种实现是在 Java SE 8 中引入的,被java.util.Arrays类用于其parallelSort()方法。这些方法类似于sort(),但通过分支/合并框架利用并发性能。在多处理器系统上运行时,大型数组的并行排序比顺序排序更快。然而,这些方法如何利用分支/合并框架超出了 Java 教程的范围。有关此信息,请参阅 Java API 文档。

另一个实现分支/合并框架的方法是使用java.util.streams包中的方法,该包是Project Lambda的一部分,计划在 Java SE 8 发布中使用。更多信息,请参阅 Lambda 表达式部分。

并发集合

原文:docs.oracle.com/javase/tutorial/essential/concurrency/collections.html

java.util.concurrent 包包含了许多对 Java 集合框架的补充。这些最容易通过提供的集合接口进行分类:

  • BlockingQueue 定义了一个先进先出的数据结构,当尝试向满队列添加或从空队列检索时会阻塞或超时。
  • ConcurrentMapjava.util.Map 的子接口,定义了有用的原子操作。这些操作仅在键存在时移除或替换键值对,或仅在键不存在时添加键值对。使这些操作原子化有助于避免同步。ConcurrentMap 的标准通用实现是 ConcurrentHashMap,它是 HashMap 的并发模拟。
  • ConcurrentNavigableMapConcurrentMap 的子接口,支持近似匹配。ConcurrentNavigableMap 的标准通用实现是 ConcurrentSkipListMap,它是 TreeMap 的并发模拟。

所有这些集合都有助于避免内存一致性错误,通过定义将一个对象添加到集合的操作与随后访问或移除该对象的操作之间的 happens-before 关系。

原子变量

原文:docs.oracle.com/javase/tutorial/essential/concurrency/atomicvars.html

java.util.concurrent.atomic包定义了支持单个变量上原子操作的类。所有类都有类似于对volatile变量进行读取和写入的getset方法。也就是说,set与同一变量上的任何后续get之间存在 happens-before 关系。原子compareAndSet方法也具有这些内存一致性特性,整数原子变量适用的简单原子算术方法也是如此。

要了解这个包可能如何使用,让我们回到最初用来演示线程干扰的Counter类:

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

使Counter免受线程干扰的一种方法是使其方法同步,就像SynchronizedCounter中那样:

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

对于这个简单的类,同步是一个可接受的解决方案。但对于一个更复杂的类,我们可能希望避免不必要同步的活跃度影响。用AtomicInteger替换int字段可以让我们在不使用同步的情况下防止线程干扰,就像AtomicCounter中那样:

import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter {
    private AtomicInteger c = new AtomicInteger(0);
    public void increment() {
        c.incrementAndGet();
    }
    public void decrement() {
        c.decrementAndGet();
    }
    public int value() {
        return c.get();
    }
}

并发随机数

原文:docs.oracle.com/javase/tutorial/essential/concurrency/threadlocalrandom.html

在 JDK 7 中,java.util.concurrent包含一个方便的类,ThreadLocalRandom,适用于期望从多个线程或ForkJoinTask中使用随机数的应用程序。

对于并发访问,使用ThreadLocalRandom而不是Math.random()会减少争用,最终提高性能。

你只需调用ThreadLocalRandom.current(),然后调用其中的方法来获取一个随机数。以下是一个示例:

int r = ThreadLocalRandom.current() .nextInt(4, 77);

进一步阅读

原文:docs.oracle.com/javase/tutorial/essential/concurrency/further.html

  • Concurrent Programming in Java: Design Principles and Pattern (2nd Edition) 作者:Doug Lea。这是一部由领先专家撰写的全面作品,他也是 Java 平台并发框架的架构师。
  • Java Concurrency in Practice 作者:Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, 和 Doug Lea。一本旨在让初学者易于理解的实用指南。
  • Effective Java Programming Language Guide (2nd Edition) 作者:Joshua Bloch。虽然这是一本通用的编程指南,但其中关于线程的章节包含了并发编程的基本“最佳实践”。
  • Concurrency: State Models & Java Programs (2nd Edition) 作者:Jeff Magee 和 Jeff Kramer。通过建模和实际示例相结合,介绍并发编程。
  • Java Concurrent Animated: 展示并发特性使用的动画。

问题和练习:并发

原文:docs.oracle.com/javase/tutorial/essential/concurrency/QandE/questions.html

问题

  1. 你能将Thread对象传递给Executor.execute吗?这样的调用有意义吗?

练习

  1. 编译并运行BadThreads.java
public class BadThreads {
    static String message;
    private static class CorrectorThread
        extends Thread {
        public void run() {
            try {
                sleep(1000); 
            } catch (InterruptedException e) {}
            // Key statement 1:
            message = "Mares do eat oats."; 
        }
    }
    public static void main(String args[])
        throws InterruptedException {
        (new CorrectorThread()).start();
        message = "Mares do not eat oats.";
        Thread.sleep(2000);
        // Key statement 2:
        System.out.println(message);
    }
}
  1. 应用程序应该打印出“Mares do eat oats.” 这是一定会发生的吗?如果不是,为什么?改变两次Sleep调用的参数会有帮助吗?如何确保所有对message的更改在主线程中可见?
  2. 修改 Guarded Blocks 中的生产者-消费者示例,使用标准库类代替Drop类。

检查你的答案。

课程:平台环境

原文:docs.oracle.com/javase/tutorial/essential/environment/index.html

应用程序在平台环境中运行,由底层操作系统、Java 虚拟机、类库和应用程序启动时提供的各种配置数据定义。本课程描述了应用程序用于检查和配置其平台环境的一些 API。本课程包括三个部分:

  • 配置工具描述了用于访问应用程序部署时提供的配置数据或应用程序用户提供的 API。
  • 系统工具描述了在SystemRuntime类中定义的各种 API。
  • 路径和类路径描述了用于配置 JDK 开发工具和其他应用程序的环境变量。

配置实用程序

原文:docs.oracle.com/javase/tutorial/essential/environment/config.html

这一部分描述了一些配置实用程序,帮助应用程序访问其启动上下文。

属性

原文:docs.oracle.com/javase/tutorial/essential/environment/properties.html

属性是作为键/值对管理的配置值。在每对中,键和值都是String值。键标识并用于检索值,就像使用变量名检索变量的值一样。例如,一个能够下载文件的应用程序可能使用名为“download.lastDirectory”的属性来跟踪用于最后下载的目录。

要管理属性,请创建java.util.Properties的实例。此类提供以下方法:

  • 从流中加载键/值对到Properties对象中,
  • 通过其键检索值,
  • 列出键及其值,
  • 枚举键,
  • 将属性保存到流中。

有关流的介绍,请参阅输入/输出流中的基本 I/O 课程。

Properties扩展了java.util.Hashtable。从Hashtable继承的一些方法支持以下操作:

  • 测试特定键或值是否在Properties对象中,
  • 获取当前键/值对的数量,
  • 删除键及其值,
  • Properties列表添加键/值对,
  • 枚举值或键,
  • 通过键检索值,
  • 查看Properties对象是否为空。

安全注意事项: 访问属性需经当前安全管理器批准。本节中的示例代码段假定为独立应用程序,这些应用程序默认没有安全管理器。同样的代码在 applet 中可能无法正常工作,具体取决于运行的浏览器。请参阅 Applet 的功能和限制中的 Java Applets 课程,了解 applet 的安全限制信息。


System类维护一个定义当前工作环境配置的Properties对象。有关这些属性的更多信息,请参阅系统属性。本节的其余部分将解释如何使用属性来管理应用程序配置。

应用程序生命周期中的属性

以下图示说明了典型应用程序如何在执行过程中使用Properties对象管理其配置数据。

  • 启动中
    第一个三个框中给出的操作发生在应用程序启动时。首先,应用程序将默认属性从一个众所周知的位置加载到Properties对象中。通常,默认属性存储在磁盘上的文件中,与应用程序的.class和其他资源文件一起。
    接下来,应用程序创建另一个Properties对象,并加载上次运行应用程序时保存的属性。许多应用程序按用户为单位存储属性,因此此步骤中加载的属性通常位于应用程序在用户主目录中维护的特定目录中的特定文件中。最后,应用程序使用默认和记忆的属性来初始化自身。
    关键在于一致性。应用程序必须始终将属性加载和保存到相同位置,以便下次执行时能够找到它们。
  • 运行中
    在应用程序执行期间,用户可能会更改一些设置,也许在首选项窗口中,并且Properties对象将更新以反映这些更改。如果要记住用户更改以供将来的会话使用,则必须保存这些更改。
  • 退出
    退出时,应用程序将属性保存到其众所周知的位置,以便在下次启动应用程序时再次加载。

设置Properties对象

以下 Java 代码执行了前一节描述的前两个步骤:加载默认属性和加载记住的属性:

. . .
// create and load default properties
Properties defaultProps = new Properties();
FileInputStream in = new FileInputStream("defaultProperties");
defaultProps.load(in);
in.close();
// create application properties with default
Properties applicationProps = new Properties(defaultProps);
// now load properties 
// from last invocation
in = new FileInputStream("appProperties");
applicationProps.load(in);
in.close();
. . .

Java 中文官方教程 2022 版(十)(2)https://developer.aliyun.com/article/1486348

相关文章
|
9天前
|
Java 测试技术 Python
《手把手教你》系列技巧篇(二十九)-java+ selenium自动化测试- Actions的相关操作上篇(详解教程)
【4月更文挑战第21天】本文介绍了Selenium中处理特殊测试场景的方法,如鼠标悬停。Selenium的Actions类提供了鼠标悬停功能,用于模拟用户在网页元素上的悬停行为。文中通过实例展示了如何使用Actions悬停并展开下拉菜单,以及在搜索时选择自动补全的字段。代码示例包括了打开百度首页,悬停在“更多”元素上显示下拉菜单并点击“音乐”,以及在搜索框输入关键词并自动补全的过程。
33 0
|
1天前
|
Java 测试技术 Python
《手把手教你》系列技巧篇(三十六)-java+ selenium自动化测试-单选和多选按钮操作-番外篇(详解教程)
【4月更文挑战第28天】本文简要介绍了自动化测试的实战应用,通过一个在线问卷调查(&lt;https://www.sojump.com/m/2792226.aspx/&gt;)为例,展示了如何遍历并点击问卷中的选项。测试思路包括找到单选和多选按钮的共性以定位元素,然后使用for循环进行点击操作。代码设计方面,提供了Java+Selenium的示例代码,通过WebDriver实现自动答题。运行代码后,可以看到控制台输出和浏览器的相应动作。文章最后做了简单的小结,强调了本次实践是对之前单选多选操作的巩固。
9 0
|
2天前
|
Java 测试技术 项目管理
Java基础教程(22)-构建工具Maven的基本使用
【4月更文挑战第22天】Maven是Java项目管理及构建工具,简化构建、测试、打包和部署等任务。遵循约定优于配置原则,核心是`pom.xml`配置文件,用于管理依赖和项目信息。安装涉及下载、解压、配置环境变量。在IDEA中使用Maven创建项目,通过`pom.xml`添加依赖和管理版本。常用命令包括`clean`、`compile`、`test`、`package`、`install`和`deploy`。IDEA支持直接执行这些命令。
|
2天前
|
NoSQL Java 关系型数据库
Java基础教程(21)-Java连接MongoDB
【4月更文挑战第21天】MongoDB是开源的NoSQL数据库,强调高性能和灵活性。Java应用通过MongoDB Java驱动与之交互,涉及MongoClient、MongoDatabase、MongoCollection和Document等组件。连接MongoDB的步骤包括:配置连接字符串、创建MongoClient、选择数据库和集合。伪代码示例展示了如何建立连接、插入和查询数据。
|
3天前
|
存储 前端开发 测试技术
《手把手教你》系列技巧篇(三十五)-java+ selenium自动化测试-单选和多选按钮操作-下篇(详解教程)
【4月更文挑战第27天】本文介绍了使用Java+Selenium进行Web自动化测试时,如何遍历和操作多选按钮的方法。文章分为两个部分,首先是一个本地HTML页面的示例,展示了多选按钮的HTML代码和页面效果,并详细解释了遍历多选按钮的思路:找到所有多选按钮的共同点,通过定位这些元素并放入list容器中,然后使用for循环遍历并操作。 第二部分介绍了在JQueryUI网站上的实战,给出了被测网址,展示了代码设计,同样使用了findElements()方法获取所有多选按钮并存储到list中,然后遍历并进行点击操作。最后,文章对整个过程进行了小结,并推荐了作者的其他自动化测试教程资源。
11 0
|
3天前
|
Java 关系型数据库 MySQL
Java基础教程(20)-Java连接mysql数据库CURD
【4月更文挑战第19天】MySQL是流行的关系型数据库管理系统,支持SQL语法。在IDEA中加载jar包到项目类路径:右击项目,选择“Open Module Settings”,添加库文件。使用JDBC连接MySQL,首先下载JDBC驱动,然后通过`Class.forName()`加载驱动,`DriverManager.getConnection()`建立连接。执行CRUD操作,例如创建表、插入数据和查询,使用`Statement`或`PreparedStatement`,并确保正确关闭数据库资源。
|
3天前
|
设计模式 算法 Java
Java基础教程(19)-设计模式简述
【4月更文挑战第19天】设计模式是软件设计中反复使用的代码设计经验,旨在提升代码的可重用性、可扩展性和可维护性。23种模式分为创建型、结构型和行为型三类。创建型模式如工厂方法、抽象工厂、建造者、原型和单例,关注对象创建与使用的分离。结构型模式涉及对象组合,如适配器、装饰器、外观等,增强结构灵活性。行为型模式专注于对象间职责分配和算法合作,包括责任链、命令、观察者等。设计模式提供标准化解决方案,促进代码交流和复用。
|
4天前
|
前端开发 测试技术 Python
《手把手教你》系列技巧篇(三十三)-java+ selenium自动化测试-单选和多选按钮操作-上篇(详解教程)
【4月更文挑战第25天】本文介绍了自动化测试中如何处理单选和多选按钮的操作,包括它们的定义、HTML代码示例以及如何判断和操作这些元素。文章通过一个简单的HTML页面展示了单选和多选框的示例,并提供了Java+Selenium实现的代码示例,演示了如何检查单选框是否选中以及如何进行全选操作。
11 0
|
5天前
|
网络协议 Java 网络架构
Java基础教程(18)-Java中的网络编程
【4月更文挑战第18天】Java网络编程简化了底层协议处理,利用Java标准库接口进行TCP/IP通信。TCP协议提供可靠传输,常用于HTTP、SMTP等协议;UDP协议则更高效但不保证可靠性。在TCP编程中,ServerSocket用于监听客户端连接,Socket实现双进程间通信。UDP编程中,DatagramSocket处理无连接的数据报文。HTTP编程可以通过HttpURLConnection发送请求并接收响应。
|
6天前
|
前端开发 Java 测试技术
《手把手教你》系列技巧篇(三十二)-java+ selenium自动化测试-select 下拉框(详解教程)
【4月更文挑战第24天】本文介绍了在自动化测试中处理HTML下拉选择(select)的方法。使用Selenium的Select类,可以通过index、value或visible text三种方式选择选项,并提供了相应的取消选择的方法。此外,文章还提供了一个示例HTML页面(select.html)和相关代码实战,演示了如何使用Selenium进行选择和取消选择操作。最后,文章提到了现代网页中类似下拉框的新设计,如12306网站的出发地选择,并给出了相应的代码示例,展示了如何定位并选择特定选项。
16 0